@veewo/dsh-better-tasks 0.0.0-stage → 0.4.5

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.
@@ -0,0 +1,75 @@
1
+ import { buildSessionEventRecords } from '@deepseek-ai/dsh-session-query'
2
+
3
+ export const FINAL_MESSAGE_MAX_CODE_POINTS = 4000
4
+
5
+ function codePoints(value) {
6
+ return [...value]
7
+ }
8
+
9
+ export function deriveFinalAssistantPreview(events, records, limit = FINAL_MESSAGE_MAX_CODE_POINTS) {
10
+ if (!Array.isArray(events) || !Array.isArray(records) || events.length !== records.length) {
11
+ throw new TypeError('events and records must be equal-length arrays')
12
+ }
13
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError('limit must be a positive safe integer')
14
+
15
+ const endedTurns = new Map()
16
+ for (const event of events) {
17
+ if (event?.type === 'turn/end') endedTurns.set(event.data.turn, event.data.reason)
18
+ }
19
+ const asOfSeq = events.length === 0 ? null : Number(events.at(-1).seq)
20
+
21
+ for (let index = events.length - 1; index >= 0; index -= 1) {
22
+ const event = events[index]
23
+ if (event?.type !== 'assistant/message' || records[index]?.surface !== 'current') continue
24
+ const endReason = endedTurns.get(event.data.turn)
25
+ if (endReason === undefined) continue
26
+ const blocks = Array.isArray(event.data.message?.content) ? event.data.message.content : []
27
+ const text = blocks
28
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '')
29
+ .map((block) => block.text)
30
+ .join('\n')
31
+ if (text === '') continue
32
+ const points = codePoints(text)
33
+ return {
34
+ asOfSeq,
35
+ final: {
36
+ seq: Number(event.seq),
37
+ turn: event.data.turn,
38
+ text: points.slice(0, limit).join(''),
39
+ interrupted: event.data.interrupted === true,
40
+ endReason,
41
+ },
42
+ truncated: points.length > limit,
43
+ totalCodePoints: points.length,
44
+ }
45
+ }
46
+
47
+ return { asOfSeq, final: null, truncated: false, totalCodePoints: 0 }
48
+ }
49
+
50
+ function sendJson(res, status, value) {
51
+ res.statusCode = status
52
+ res.setHeader('content-type', 'application/json; charset=utf-8')
53
+ res.setHeader('cache-control', 'no-store')
54
+ res.end(JSON.stringify(value))
55
+ }
56
+
57
+ export function createFinalMessageHttpHandler({ isPinned, readSession, buildRecords = buildSessionEventRecords }) {
58
+ return async (req, res) => {
59
+ if (req.method !== 'GET') return sendJson(res, 405, { code: 'method-not-allowed' })
60
+ const sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId')?.trim()
61
+ if (!sessionId || !isPinned(sessionId)) {
62
+ return sendJson(res, 404, { code: 'better-tasks/final-session-not-pinned', message: 'session is not pinned' })
63
+ }
64
+ try {
65
+ const snapshot = await readSession(sessionId)
66
+ const records = buildRecords(sessionId, snapshot.events)
67
+ return sendJson(res, 200, { sessionId, ...deriveFinalAssistantPreview(snapshot.events, records) })
68
+ } catch (error) {
69
+ return sendJson(res, 500, {
70
+ code: 'better-tasks/final-read-failed',
71
+ message: String(error?.message ?? error),
72
+ })
73
+ }
74
+ }
75
+ }
package/src/index.js ADDED
@@ -0,0 +1,220 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { z } from 'zod'
3
+ import Schema from '@deepseek-ai/schemastery'
4
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
5
+ import {
6
+ PIN_QUEUE_SCHEMA_VERSION,
7
+ PinQueueError,
8
+ publicPinQueue,
9
+ } from './pin-model.mjs'
10
+ import { installProjectAppearance } from './project-appearance.mjs'
11
+ import { createFinalMessageHttpHandler } from './final-message.mjs'
12
+ import { createNativePinHost } from './native-pin-host.mjs'
13
+ import { createTaskExecutionProjection } from './task-execution.mjs'
14
+
15
+ export const name = 'dsh-better-tasks'
16
+ export const inject = ['storageDomain', 'sessionController', 'sessionQuery', 'workspaceRegistry', 'webServer']
17
+ export const TASK_SETTINGS_NAMESPACE = 'better-tasks-ui'
18
+ export const TASK_SETTINGS_DEFAULTS = Object.freeze({
19
+ detailFontSize: 13,
20
+ defaultTodoExpanded: false,
21
+ defaultGoalExpanded: false,
22
+ defaultFinalExpanded: true,
23
+ defaultInputExpanded: false,
24
+ columnLayout: 'auto',
25
+ titleEmphasis: 'task',
26
+ sidebarView: 'native',
27
+ })
28
+ export const TaskSettingsSchema = Schema.object({
29
+ // 0.2 exposes only volatile Config fields through settings.describe/forms.
30
+ // Its .volatile() is exactly .extra('volatile', true); use that public metadata
31
+ // API so the retained 0.1.5 build-time Schemastery can parse the same schema.
32
+ detailFontSize: Schema.number().step(1).min(11).max(16).default(13).extra('volatile', true),
33
+ defaultTodoExpanded: Schema.boolean().default(false).extra('volatile', true),
34
+ defaultGoalExpanded: Schema.boolean().default(false).extra('volatile', true),
35
+ defaultFinalExpanded: Schema.boolean().default(true).extra('volatile', true),
36
+ defaultInputExpanded: Schema.boolean().default(false).extra('volatile', true),
37
+ columnLayout: Schema.union(['auto', 'single', 'double']).default('auto').extra('volatile', true),
38
+ titleEmphasis: Schema.union(['project', 'task']).default('task').extra('volatile', true),
39
+ // D1: the sidebar body the browser shows. Host-persisted, so both generations
40
+ // read and write it through the same Config path.
41
+ sidebarView: Schema.union(['native', 'tasks']).default('native').extra('volatile', true),
42
+ })
43
+
44
+ /**
45
+ * The plugin's own Config schema.
46
+ *
47
+ * 0.2 derives both the settings form and the persistence namespace from a plugin's
48
+ * entry: the namespace is the loader entry id and the fields are this schema's
49
+ * keys, so the seven preference fields have to live here. The 0.1.5 runtime entry
50
+ * point `settings.register(namespace, schema)` does not exist in 0.2 — calling it
51
+ * threw inside a non-blocking inject callback, which is exactly why preferences
52
+ * never persisted and nothing reported itself as broken.
53
+ *
54
+ * {@link TASK_SETTINGS_NAMESPACE} is therefore both the 0.1.5 namespace and the
55
+ * required 0.2 row id: under the same name the values 0.1.5 wrote are read in
56
+ * place and a rollback loses nothing, so no copy migration is needed. A row named
57
+ * anything else starts at these defaults and leaves the legacy key untouched.
58
+ */
59
+ export const Config = TaskSettingsSchema
60
+
61
+ const pinQueueRecord = z.object({
62
+ schemaVersion: z.literal(PIN_QUEUE_SCHEMA_VERSION),
63
+ revision: z.number().int().nonnegative(),
64
+ sessionIds: z.array(z.string().min(1)).refine((items) => new Set(items).size === items.length, 'sessionIds must be unique'),
65
+ updatedAt: z.string().min(1),
66
+ })
67
+
68
+ export const pinQueueDomain = defineDomain({
69
+ name: 'better_tasks_pin_queue',
70
+ version: 1,
71
+ tables: { queues: domainTable(pinQueueRecord) },
72
+ })
73
+
74
+ // A new unit, not a field/table extension of the rollback-readable legacy queue.
75
+ export const nativePinOrderDomain = defineDomain({
76
+ name: 'better_tasks_native_order', version: 1,
77
+ tables: { state: domainTable(pinQueueRecord.extend({
78
+ // Read compatibility only. sessionIds is the sole order; new writes omit
79
+ // the former manual subset and time-derived sorting metadata.
80
+ manualSessionIds: z.array(z.string().min(1)).refine((ids) => new Set(ids).size === ids.length).optional(),
81
+ manualOrderAt: z.number().int().nonnegative().optional(),
82
+ // Old admission/work caches are accepted, never read to determine order.
83
+ taskCreatedAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
84
+ taskFirstWorkAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
85
+ migration: z.object({
86
+ status: z.enum(['running', 'done', 'partial']),
87
+ sourceRevision: z.number().int().nonnegative().nullable(),
88
+ entries: z.array(z.object({
89
+ sessionId: z.string().min(1),
90
+ status: z.enum(['pending', 'attempted', 'imported', 'skipped', 'failed', 'uncertain']),
91
+ detail: z.string().optional(),
92
+ })),
93
+ }),
94
+ })) },
95
+ })
96
+
97
+ function sendJson(res, status, value) {
98
+ res.statusCode = status
99
+ res.setHeader('content-type', 'application/json; charset=utf-8')
100
+ res.setHeader('cache-control', 'no-store')
101
+ res.end(JSON.stringify(value))
102
+ }
103
+
104
+ async function readJson(req) {
105
+ const chunks = []
106
+ let size = 0
107
+ for await (const chunk of req) {
108
+ size += chunk.length
109
+ if (size > 64 * 1024) throw new PinQueueError('better-tasks/request-too-large', 'request body exceeds 64 KiB')
110
+ chunks.push(chunk)
111
+ }
112
+ try {
113
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
114
+ } catch {
115
+ throw new PinQueueError('better-tasks/invalid-json', 'request body must be valid JSON')
116
+ }
117
+ }
118
+
119
+ function errorStatus(error) {
120
+ if (error?.code === 'better-tasks/revision-conflict') return 409
121
+ if (error?.code === 'better-tasks/session-not-eligible') return 404
122
+ if (error?.code === 'better-tasks/request-too-large') return 413
123
+ if (typeof error?.code === 'string' && error.code.startsWith('better-tasks/')) return 400
124
+ return 500
125
+ }
126
+
127
+ export function createPinQueueHttpHandler({ current, eligibleSessionIds, retainedSessionIds = eligibleSessionIds, mutate, prune }) {
128
+ return async (req, res) => {
129
+ try {
130
+ if (req.method === 'GET') {
131
+ const retained = await retainedSessionIds()
132
+ return sendJson(res, 200, { queue: publicPinQueue(await prune(retained)) })
133
+ }
134
+ if (req.method !== 'POST') return sendJson(res, 405, { code: 'method-not-allowed' })
135
+ const input = await readJson(req)
136
+ if (input?.action === 'pin' || input?.action === 'pin-created') {
137
+ const eligible = input.action === 'pin-created' ? await retainedSessionIds() : await eligibleSessionIds()
138
+ if (!eligible.has(input.sessionId)) {
139
+ throw new PinQueueError('better-tasks/session-not-eligible', `session is not eligible for pinning: ${String(input.sessionId)}`)
140
+ }
141
+ }
142
+ const queue = await mutate(input)
143
+ return sendJson(res, 200, { queue: publicPinQueue(queue) })
144
+ } catch (error) {
145
+ const code = typeof error?.code === 'string' ? error.code : 'better-tasks/write-failed'
146
+ return sendJson(res, errorStatus(error), {
147
+ code,
148
+ message: String(error?.message ?? error),
149
+ ...(error?.details === undefined ? {} : { details: error.details }),
150
+ ...(code === 'better-tasks/revision-conflict' ? { queue: publicPinQueue(current()) } : {}),
151
+ })
152
+ }
153
+ }
154
+ }
155
+
156
+ export function createNativePinHttpHandler(controller) {
157
+ return async (req, res) => {
158
+ try {
159
+ if (req.method === 'GET') return sendJson(res, 200, controller.read())
160
+ if (req.method !== 'POST') return sendJson(res, 405, { code: 'method-not-allowed' })
161
+ return sendJson(res, 200, await controller.mutate(await readJson(req)))
162
+ } catch (error) {
163
+ const status = error?.status ?? (error?.name === 'WorkspaceUnknownSessionError' ? 404 : error?.name === 'WorkspaceArchivedSessionPinError' ? 400 : errorStatus(error))
164
+ return sendJson(res, status, { ...controller.current(), code: error?.code ?? 'better-tasks/write-failed', message: String(error?.message ?? error) })
165
+ }
166
+ }
167
+ }
168
+
169
+ export async function apply(ctx) {
170
+ // Optional projection capability must never hold core-shell activation.
171
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
172
+ projectionCtx.sessionProjections.register(createTaskExecutionProjection(z))
173
+ })
174
+ ctx.inject(['settings'], (settingsCtx) => {
175
+ // 0.1.5: the Host settings service accepted a runtime namespace registration.
176
+ if (typeof settingsCtx.settings.register === 'function') {
177
+ settingsCtx.settings.register(TASK_SETTINGS_NAMESPACE, TaskSettingsSchema)
178
+ return
179
+ }
180
+ // 0.2: the schema comes from `Config` above, so the only thing left to declare
181
+ // is the instance-level UI policy, attributed to this plugin's fiber.
182
+ if (typeof settingsCtx.settings.configure === 'function') {
183
+ settingsCtx.effect(
184
+ () => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
185
+ 'dsh-better-tasks: settings presentation',
186
+ )
187
+ }
188
+ })
189
+ await installProjectAppearance(ctx)
190
+ // No migration/storage wait in plugin startup. Native controls are independent
191
+ // of this optional ordering feature; the first feature request starts its work.
192
+ const open = async (spec, tableName) => {
193
+ const domain = await ctx.storageDomain.open(spec)
194
+ return { table: domain.table(tableName), close: () => domain.close() }
195
+ }
196
+ const controller = createNativePinHost({
197
+ registry: ctx.workspaceRegistry,
198
+ openOrder: () => open(nativePinOrderDomain, 'state'),
199
+ openLegacy: () => open(pinQueueDomain, 'queues'),
200
+ subscribe: listener => ctx.on('domain/changed', listener),
201
+ })
202
+ const handler = createNativePinHttpHandler(controller)
203
+
204
+ ctx.effect(() => ctx.webServer.register({
205
+ kind: 'exact',
206
+ path: '/api/better-tasks/pins',
207
+ handler,
208
+ }), 'dsh-better-tasks.pin-http')
209
+ ctx.effect(() => ctx.webServer.register({
210
+ kind: 'exact',
211
+ path: '/api/better-tasks/final-message',
212
+ handler: createFinalMessageHttpHandler({
213
+ isPinned: (sessionId) => ctx.workspaceRegistry.pinnedSessionIds.includes(String(sessionId)),
214
+ readSession: (sessionId) => ctx.sessionQuery.readSession(sessionId),
215
+ }),
216
+ }), 'dsh-better-tasks.final-message-http')
217
+ ctx.effect(() => async () => {
218
+ await controller.dispose()
219
+ }, 'dsh-better-tasks.native-pin-controller-close')
220
+ }
@@ -0,0 +1,261 @@
1
+ // Official Workspace pins own membership. This controller owns one complete order
2
+ // and a sealed, at-most-once legacy import journal; it never compensates native Pin.
3
+ const NATIVE_ORDER_KEY = 'global'
4
+ const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index])
5
+ const ownedIds = value => {
6
+ if (!Array.isArray(value) || value.some(id => typeof id !== 'string' || id === '') || new Set(value).size !== value.length) throw new Error('Invalid native pin/order identities')
7
+ return [...value]
8
+ }
9
+ const messageOf = error => String(error?.message ?? error).slice(0, 1000)
10
+ const changedIds = (a, b) => {
11
+ const before = new Set(a), after = new Set(b)
12
+ return [...a.filter(id => !after.has(id)), ...b.filter(id => !before.has(id))]
13
+ }
14
+ // New members join the complete sequence immediately. Pure native array reorders
15
+ // cannot move existing members; neither clocks nor Session content are involved.
16
+ const mergeOrder = (sequence, pins, reserved = new Set()) => {
17
+ const members = new Set(pins), ranked = new Set(sequence)
18
+ return [...pins.filter(id => !ranked.has(id)), ...sequence.filter(id => members.has(id) || reserved.has(id))]
19
+ }
20
+ export class NativePinError extends Error {
21
+ constructor(code, message, status = 400) { super(message); this.code = code; this.status = status }
22
+ }
23
+
24
+ /** open* returns {table,close}; subscribe returns an event unsubscriber. */
25
+ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe, now = () => new Date().toISOString() }) {
26
+ let pins = [], archived = [], rank = [], sequence = [], revision = 0, updatedAt = now(), epoch = 0
27
+ let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true, hydrated = false
28
+ let tail = Promise.resolve(), initialization, migration, reconcileQueued = false, persistenceError = null
29
+ const snapshots = [], handles = new Set(), touched = new Set(), reservedIds = new Set(), warnings = [], warningSet = new Set()
30
+ const warn = error => { const text = messageOf(error); if (!warningSet.has(text)) { warningSet.add(text); warnings.push(text); if (warnings.length > 30) warnings.shift() } }
31
+ const enqueue = work => {
32
+ const next = tail.then(() => { if (disposed) throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503); return work() })
33
+ tail = next.catch(() => {})
34
+ return next
35
+ }
36
+ const orderedIds = () => sequence.filter(id => pins.includes(id))
37
+ const orderReady = () => !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial')
38
+ const response = () => {
39
+ const errors = [...warnings, ...(persistenceError ? [persistenceError] : [])]
40
+ return {
41
+ queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
42
+ ...(errors.length ? { warnings: errors } : {}),
43
+ migration: { status: phase },
44
+ writable: orderReady() && !persistenceError && sameIds(rank, orderedIds()),
45
+ }
46
+ }
47
+ const reservations = () => new Set([...reservedIds].filter(id => !touched.has(id) && !archived.includes(id)))
48
+ const nativeSnapshot = (nextPins, nextArchived, observe = true) => {
49
+ const a = ownedIds(nextArchived), excluded = new Set(a), p = ownedIds(nextPins).filter(id => !excluded.has(id))
50
+ nativeValid = true
51
+ if (sameIds(p, pins) && sameIds(a, archived)) return
52
+ const membershipChanged = changedIds(pins, p).length > 0 || changedIds(archived, a).length > 0
53
+ if (observe) for (const id of [...changedIds(pins, p), ...changedIds(archived, a)]) touched.add(id)
54
+ pins = p; archived = a
55
+ if (!observe) { sequence = [...p]; return }
56
+ if (membershipChanged) epoch++
57
+ // Retain all transitions while opening storage, including Unpin -> Pin of
58
+ // the same id. One latest snapshot would lose its re-insertion position.
59
+ if (!hydrated) snapshots.push([...p])
60
+ sequence = mergeOrder(sequence, pins, reservations())
61
+ if (membershipChanged) start()
62
+ scheduleReconcile()
63
+ }
64
+ function scheduleReconcile() {
65
+ if (!orderReady() || reconcileQueued) return
66
+ reconcileQueued = true
67
+ void enqueue(async () => {
68
+ try { await reconcile() } finally { reconcileQueued = false }
69
+ }).catch(() => {})
70
+ }
71
+ // Storage emits before WorkspaceRegistry's private cache is assigned. Always
72
+ // consume the owned event payload, never registry getters inside this callback.
73
+ const off = subscribe(change => {
74
+ if (disposed || change?.domain !== 'workspace' || change.table !== '' || change.key !== '' || change.operation !== 'put') return
75
+ try { nativeSnapshot(change.value?.pinnedSessionIds, change.value?.archivedSessionIds) }
76
+ catch (error) { nativeValid = false; warn(error) }
77
+ })
78
+ try { nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds, false) }
79
+ catch (error) { nativeValid = false; warn(error); phase = 'error' }
80
+ const initialPins = [...pins], initiallyPinned = new Set(pins)
81
+
82
+ async function save(next) {
83
+ const nextRank = ownedIds(next.sessionIds)
84
+ // Legacy fields remain readable in the v1 schema, but are no longer writers
85
+ // or authorities. Every new commit contains just one complete order.
86
+ const { manualSessionIds, manualOrderAt, taskCreatedAtBySession, taskFirstWorkAtBySession, ...base } = next
87
+ const value = { ...base, schemaVersion: 1, revision: revision + 1, sessionIds: nextRank, updatedAt: now() }
88
+ try {
89
+ if (record === undefined) await table.put(NATIVE_ORDER_KEY, value)
90
+ else await table.update(NATIVE_ORDER_KEY, () => value)
91
+ } catch (error) {
92
+ persistenceError = 'Task order was not saved; official Pin membership is unchanged: ' + messageOf(error)
93
+ throw error
94
+ }
95
+ // Never advance a committed revision/rank before storage acknowledges. The
96
+ // live sequence may already include newer events received during this write.
97
+ record = value; rank = [...value.sessionIds]; revision = value.revision; updatedAt = value.updatedAt
98
+ persistenceError = null
99
+ return value
100
+ }
101
+ async function reconcile() {
102
+ if (!orderReady() || record === undefined) return
103
+ while (!sameIds(rank, orderedIds())) await save({ ...record, sessionIds: orderedIds() })
104
+ persistenceError = null
105
+ }
106
+ async function open(factory) {
107
+ const handle = await factory()
108
+ if (disposed) { await handle.close(); throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503) }
109
+ handles.add(handle)
110
+ return handle
111
+ }
112
+ async function close(handle) { if (!handles.delete(handle)) return; await handle.close() }
113
+ const replaceEntry = (id, patch) => ({ ...record, sessionIds: [...sequence], migration: { ...record.migration,
114
+ entries: record.migration.entries.map(entry => entry.sessionId === id ? { ...entry, ...patch } : entry) } })
115
+
116
+ async function runMigration() {
117
+ for (const entry of record.migration.entries) {
118
+ if (disposed) return
119
+ if (entry.status !== 'pending') continue
120
+ await enqueue(async () => {
121
+ if (!nativeValid) throw new Error('Native pin state is unavailable; legacy import has stopped')
122
+ if (touched.has(entry.sessionId) || pins.includes(entry.sessionId) || archived.includes(entry.sessionId)) {
123
+ await save(replaceEntry(entry.sessionId, { status: 'skipped', detail: 'Native pin/archive activity takes precedence' })); return
124
+ }
125
+ // Durable BEFORE the side effect. An interrupted journal never retries.
126
+ await save(replaceEntry(entry.sessionId, { status: 'attempted' }))
127
+ if (disposed) return
128
+ if (!nativeValid) throw new Error('Native pin state became unavailable before legacy import')
129
+ if (touched.has(entry.sessionId) || pins.includes(entry.sessionId) || archived.includes(entry.sessionId)) {
130
+ await save(replaceEntry(entry.sessionId, { status: 'skipped', detail: 'Native activity while import was preparing' })); return
131
+ }
132
+ const observedEpoch = epoch
133
+ let outcome
134
+ try {
135
+ await registry.pinSession(entry.sessionId)
136
+ if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
137
+ outcome = { status: 'imported' }
138
+ } catch (error) {
139
+ const known = error?.name === 'WorkspaceUnknownSessionError' || error?.name === 'WorkspaceArchivedSessionPinError'
140
+ outcome = { status: known ? 'skipped' : 'failed', detail: messageOf(error) }
141
+ warn('Legacy task ' + entry.sessionId + ' was not imported: ' + outcome.detail)
142
+ }
143
+ await save(replaceEntry(entry.sessionId, outcome))
144
+ })
145
+ }
146
+ if (disposed) return
147
+ await enqueue(async () => {
148
+ const partial = record.migration.entries.some(entry => entry.status === 'failed' || entry.status === 'attempted')
149
+ const status = partial ? 'partial' : 'done'
150
+ reservedIds.clear()
151
+ sequence = orderedIds() // Remove only the unimported reservations.
152
+ await save({ ...record, sessionIds: [...sequence], migration: { ...record.migration, status } })
153
+ phase = status
154
+ await reconcile()
155
+ })
156
+ }
157
+ function hydrate(base, reserved = new Set()) {
158
+ for (const id of reserved) reservedIds.add(id)
159
+ sequence = mergeOrder(base, initialPins, reserved)
160
+ for (const snapshot of snapshots) sequence = mergeOrder(sequence, snapshot, reserved)
161
+ snapshots.length = 0; hydrated = true
162
+ }
163
+ async function initialize() {
164
+ if (!nativeValid) throw new Error('Native pin state is unavailable')
165
+ const order = await open(openOrder); table = order.table
166
+ const stored = table.get(NATIVE_ORDER_KEY)
167
+ if (stored !== undefined) {
168
+ rank = ownedIds(stored.sessionIds); record = stored; revision = stored.revision; updatedAt = stored.updatedAt
169
+ hydrate(rank)
170
+ for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn('Legacy task ' + entry.sessionId + ': ' + entry.detail)
171
+ if (stored.migration.status === 'running') {
172
+ warn('Legacy pin migration was interrupted; unconfirmed entries will not be retried automatically. The original queue is preserved.')
173
+ await enqueue(async () => {
174
+ await save({ ...record, sessionIds: orderedIds(), migration: { ...record.migration, status: 'partial', entries: record.migration.entries.map(entry => entry.status === 'pending' || entry.status === 'attempted' ? { ...entry, status: 'uncertain', detail: 'Interrupted import; use the official Pin control to recover explicitly' } : entry) } })
175
+ })
176
+ phase = 'partial'
177
+ } else phase = stored.migration.status
178
+ await enqueue(reconcile)
179
+ return
180
+ }
181
+ // The legacy unit is opened unchanged, read and closed; never erase its data.
182
+ const legacy = await open(openLegacy)
183
+ let seed
184
+ try { seed = legacy.table.get(NATIVE_ORDER_KEY); if (seed !== undefined) ownedIds(seed.sessionIds) }
185
+ finally { await close(legacy) }
186
+ const seedIds = seed?.sessionIds ?? [], alreadyNative = new Set([...initiallyPinned, ...pins])
187
+ const entries = seedIds.map(sessionId => ({ sessionId,
188
+ status: alreadyNative.has(sessionId) || touched.has(sessionId) || archived.includes(sessionId) ? 'skipped' : 'pending',
189
+ ...(alreadyNative.has(sessionId) ? { detail: 'Already officially pinned; never an import target' } : {}),
190
+ }))
191
+ const pending = new Set(entries.filter(entry => entry.status === 'pending').map(entry => entry.sessionId))
192
+ hydrate([...seedIds, ...initialPins.filter(id => !seedIds.includes(id))], pending)
193
+ // No old import work means no fake migration phase or extra journal writes.
194
+ const status = pending.size ? 'running' : 'done'
195
+ await enqueue(async () => {
196
+ await save({ sessionIds: [...sequence], migration: { status, sourceRevision: seed?.revision ?? null, entries } })
197
+ phase = status
198
+ if (status === 'done') await reconcile()
199
+ })
200
+ if (status === 'running') migration = runMigration().catch(error => { if (!disposed) { warn('Legacy migration stopped: ' + messageOf(error) + '. The original queue is preserved.'); phase = 'error' } })
201
+ }
202
+ function start() {
203
+ if (started || disposed) return
204
+ started = true; phase = 'initializing'
205
+ initialization = initialize().catch(error => {
206
+ // A loaded terminal journal with a dirty sequence can retry a failed save.
207
+ // Opening/fencing/import failures remain terminal and must not replay Pin.
208
+ if (!disposed && !orderReady()) { phase = 'error'; warn('Task order initialization failed: ' + messageOf(error)) }
209
+ })
210
+ }
211
+ function requireCommand(command) {
212
+ if (command === null || typeof command !== 'object' || typeof command.sessionId !== 'string' || command.sessionId === '') throw new NativePinError('better-tasks/invalid-request', 'A Session identity is required')
213
+ if (!['move', 'pin', 'pin-created', 'unpin'].includes(command.action)) throw new NativePinError('better-tasks/invalid-request', 'Unsupported task operation')
214
+ }
215
+ return {
216
+ read() { start(); scheduleReconcile(); return response() },
217
+ current: response,
218
+ async mutate(command) {
219
+ requireCommand(command)
220
+ if (command.action !== 'move') touched.add(command.sessionId)
221
+ start()
222
+ if (command.action === 'move' && !orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task order is initializing, importing or unavailable; official Pin remains usable', 503)
223
+ return enqueue(async () => {
224
+ if (command.action !== 'move') {
225
+ const observedEpoch = epoch
226
+ if (command.action === 'unpin') await registry.unpinSession(command.sessionId)
227
+ else await registry.pinSession(command.sessionId)
228
+ if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
229
+ try { await reconcile() } catch { /* save exposes persistenceError; never undo the native result. */ }
230
+ return response()
231
+ }
232
+ if (!Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0) throw new NativePinError('better-tasks/invalid-request', 'expectedRevision must be a non-negative safe integer')
233
+ if (!orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is unavailable; official Pin remains usable', 503)
234
+ await reconcile()
235
+ if (command.expectedRevision !== revision) throw new NativePinError('better-tasks/revision-conflict', 'Task order changed', 409)
236
+ const ids = orderedIds(), before = command.beforeSessionId ?? null
237
+ if (!ids.includes(command.sessionId)) throw new NativePinError('better-tasks/not-pinned', 'Session is no longer officially pinned', 409)
238
+ if (before !== null && (typeof before !== 'string' || !ids.includes(before))) throw new NativePinError('better-tasks/anchor-not-pinned', 'Move anchor is no longer officially pinned', 409)
239
+ if (before === command.sessionId) return response()
240
+ const next = ids.filter(id => id !== command.sessionId)
241
+ next.splice(before === null ? next.length : next.indexOf(before), 0, command.sessionId)
242
+ if (sameIds(next, ids)) return response()
243
+ const observedEpoch = epoch
244
+ await save({ ...record, sessionIds: next })
245
+ if (observedEpoch !== epoch) {
246
+ await reconcile()
247
+ throw new NativePinError('better-tasks/revision-conflict', 'Official membership changed while saving task order', 409)
248
+ }
249
+ sequence = next
250
+ return response()
251
+ })
252
+ },
253
+ async settled() { await initialization; await migration; await tail },
254
+ async dispose() {
255
+ if (disposed) return
256
+ disposed = true; off?.()
257
+ await initialization; await migration; await tail
258
+ await Promise.all([...handles].map(close))
259
+ },
260
+ }
261
+ }