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

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,222 @@
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, taskFirstWorkAt } 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
+ // Absent on older auto-seeded records; only an explicit drag writes this.
79
+ manualSessionIds: z.array(z.string().min(1)).refine((ids) => new Set(ids).size === ids.length).optional(),
80
+ manualOrderAt: z.number().int().nonnegative().optional(),
81
+ // Task admission time may differ from an older/reused Session header.
82
+ taskCreatedAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
83
+ taskFirstWorkAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
84
+ migration: z.object({
85
+ status: z.enum(['running', 'done', 'partial']),
86
+ sourceRevision: z.number().int().nonnegative().nullable(),
87
+ entries: z.array(z.object({
88
+ sessionId: z.string().min(1),
89
+ status: z.enum(['pending', 'attempted', 'imported', 'skipped', 'failed', 'uncertain']),
90
+ detail: z.string().optional(),
91
+ })),
92
+ }),
93
+ })) },
94
+ })
95
+
96
+ function sendJson(res, status, value) {
97
+ res.statusCode = status
98
+ res.setHeader('content-type', 'application/json; charset=utf-8')
99
+ res.setHeader('cache-control', 'no-store')
100
+ res.end(JSON.stringify(value))
101
+ }
102
+
103
+ async function readJson(req) {
104
+ const chunks = []
105
+ let size = 0
106
+ for await (const chunk of req) {
107
+ size += chunk.length
108
+ if (size > 64 * 1024) throw new PinQueueError('better-tasks/request-too-large', 'request body exceeds 64 KiB')
109
+ chunks.push(chunk)
110
+ }
111
+ try {
112
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
113
+ } catch {
114
+ throw new PinQueueError('better-tasks/invalid-json', 'request body must be valid JSON')
115
+ }
116
+ }
117
+
118
+ function errorStatus(error) {
119
+ if (error?.code === 'better-tasks/revision-conflict') return 409
120
+ if (error?.code === 'better-tasks/session-not-eligible') return 404
121
+ if (error?.code === 'better-tasks/request-too-large') return 413
122
+ if (typeof error?.code === 'string' && error.code.startsWith('better-tasks/')) return 400
123
+ return 500
124
+ }
125
+
126
+ export function createPinQueueHttpHandler({ current, eligibleSessionIds, retainedSessionIds = eligibleSessionIds, mutate, prune }) {
127
+ return async (req, res) => {
128
+ try {
129
+ if (req.method === 'GET') {
130
+ const retained = await retainedSessionIds()
131
+ return sendJson(res, 200, { queue: publicPinQueue(await prune(retained)) })
132
+ }
133
+ if (req.method !== 'POST') return sendJson(res, 405, { code: 'method-not-allowed' })
134
+ const input = await readJson(req)
135
+ if (input?.action === 'pin' || input?.action === 'pin-created') {
136
+ const eligible = input.action === 'pin-created' ? await retainedSessionIds() : await eligibleSessionIds()
137
+ if (!eligible.has(input.sessionId)) {
138
+ throw new PinQueueError('better-tasks/session-not-eligible', `session is not eligible for pinning: ${String(input.sessionId)}`)
139
+ }
140
+ }
141
+ const queue = await mutate(input)
142
+ return sendJson(res, 200, { queue: publicPinQueue(queue) })
143
+ } catch (error) {
144
+ const code = typeof error?.code === 'string' ? error.code : 'better-tasks/write-failed'
145
+ return sendJson(res, errorStatus(error), {
146
+ code,
147
+ message: String(error?.message ?? error),
148
+ ...(error?.details === undefined ? {} : { details: error.details }),
149
+ ...(code === 'better-tasks/revision-conflict' ? { queue: publicPinQueue(current()) } : {}),
150
+ })
151
+ }
152
+ }
153
+ }
154
+
155
+ export function createNativePinHttpHandler(controller) {
156
+ return async (req, res) => {
157
+ try {
158
+ if (req.method === 'GET') return sendJson(res, 200, controller.read())
159
+ if (req.method !== 'POST') return sendJson(res, 405, { code: 'method-not-allowed' })
160
+ return sendJson(res, 200, await controller.mutate(await readJson(req)))
161
+ } catch (error) {
162
+ const status = error?.status ?? (error?.name === 'WorkspaceUnknownSessionError' ? 404 : error?.name === 'WorkspaceArchivedSessionPinError' ? 400 : errorStatus(error))
163
+ return sendJson(res, status, { ...controller.current(), code: error?.code ?? 'better-tasks/write-failed', message: String(error?.message ?? error) })
164
+ }
165
+ }
166
+ }
167
+
168
+ export async function apply(ctx) {
169
+ // Optional projection capability must never hold core-shell activation.
170
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
171
+ projectionCtx.sessionProjections.register(createTaskExecutionProjection(z))
172
+ })
173
+ ctx.inject(['settings'], (settingsCtx) => {
174
+ // 0.1.5: the Host settings service accepted a runtime namespace registration.
175
+ if (typeof settingsCtx.settings.register === 'function') {
176
+ settingsCtx.settings.register(TASK_SETTINGS_NAMESPACE, TaskSettingsSchema)
177
+ return
178
+ }
179
+ // 0.2: the schema comes from `Config` above, so the only thing left to declare
180
+ // is the instance-level UI policy, attributed to this plugin's fiber.
181
+ if (typeof settingsCtx.settings.configure === 'function') {
182
+ settingsCtx.effect(
183
+ () => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
184
+ 'dsh-better-tasks: settings presentation',
185
+ )
186
+ }
187
+ })
188
+ await installProjectAppearance(ctx)
189
+ // No migration/storage wait in plugin startup. Native controls are independent
190
+ // of this optional ordering feature; the first feature request starts its work.
191
+ const open = async (spec, tableName) => {
192
+ const domain = await ctx.storageDomain.open(spec)
193
+ return { table: domain.table(tableName), close: () => domain.close() }
194
+ }
195
+ const controller = createNativePinHost({
196
+ registry: ctx.workspaceRegistry,
197
+ listSessions: (signal) => ctx.sessionQuery.listSessions(signal),
198
+ readTaskStartedAt: async (header, signal) => typeof ctx.sessionQuery.listEvents === 'function'
199
+ ? taskFirstWorkAt(await ctx.sessionQuery.listEvents(header.id, signal)) : undefined,
200
+ openOrder: () => open(nativePinOrderDomain, 'state'),
201
+ openLegacy: () => open(pinQueueDomain, 'queues'),
202
+ subscribe: listener => ctx.on('domain/changed', listener),
203
+ })
204
+ const handler = createNativePinHttpHandler(controller)
205
+
206
+ ctx.effect(() => ctx.webServer.register({
207
+ kind: 'exact',
208
+ path: '/api/better-tasks/pins',
209
+ handler,
210
+ }), 'dsh-better-tasks.pin-http')
211
+ ctx.effect(() => ctx.webServer.register({
212
+ kind: 'exact',
213
+ path: '/api/better-tasks/final-message',
214
+ handler: createFinalMessageHttpHandler({
215
+ isPinned: (sessionId) => ctx.workspaceRegistry.pinnedSessionIds.includes(String(sessionId)),
216
+ readSession: (sessionId) => ctx.sessionQuery.readSession(sessionId),
217
+ }),
218
+ }), 'dsh-better-tasks.final-message-http')
219
+ ctx.effect(() => async () => {
220
+ await controller.dispose()
221
+ }, 'dsh-better-tasks.native-pin-controller-close')
222
+ }
@@ -0,0 +1,309 @@
1
+ // Official Workspace pins own membership. This controller owns rank and a sealed,
2
+ // at-most-once legacy import journal only; it never compensates native operations.
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 projectIds = (rank, pins) => {
10
+ const pinned = new Set(pins), ranked = new Set(rank)
11
+ return [...rank.filter(id => pinned.has(id)), ...pins.filter(id => !ranked.has(id))]
12
+ }
13
+ const messageOf = error => String(error?.message ?? error).slice(0, 1000)
14
+ const changedIds = (a, b) => {
15
+ const before = new Set(a), after = new Set(b)
16
+ return [...a.filter(id => !after.has(id)), ...b.filter(id => !before.has(id))]
17
+ }
18
+ export class NativePinError extends Error {
19
+ constructor(code, message, status = 400) { super(message); this.code = code; this.status = status }
20
+ }
21
+
22
+ /** First admitted work, not empty-file preparation time or later activity. */
23
+ export function taskFirstWorkAt(events) {
24
+ for (const event of events) {
25
+ if (['agent/inbox/spliced', 'user/message', 'turn/start'].includes(event.type)
26
+ && Number.isSafeInteger(event.time) && event.time >= 0) return event.time
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** open* returns {table,close}; subscribe returns an event unsubscriber. */
32
+ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe, listSessions, readTaskStartedAt, now = () => new Date().toISOString(), taskCreationNow = Date.now }) {
33
+ let pins = [], archived = [], rank = [], revision = 0, updatedAt = now(), epoch = 0
34
+ let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true
35
+ let tail = Promise.resolve(), initialization, migration, reconcileQueued = false
36
+ const handles = new Set(), touched = new Set(), warnings = [], warningSet = new Set()
37
+ const warn = error => { const text = messageOf(error); if (!warningSet.has(text)) { warningSet.add(text); warnings.push(text); if (warnings.length > 30) warnings.shift() } }
38
+ const enqueue = work => {
39
+ const next = tail.then(() => { if (disposed) throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503); return work() })
40
+ tail = next.catch(() => {})
41
+ return next
42
+ }
43
+ const createdAt = new Map(), firstWorkAt = new Map(), headers = new Map(), admittedHere = new Set(), lifetime = new AbortController()
44
+ let metadataDirty = false
45
+ const newestFirst = (ids) => [...ids].sort((a, b) => {
46
+ const left = createdAt.get(a), right = createdAt.get(b)
47
+ if (left === undefined || right === undefined) return left === right ? 0 : left === undefined ? 1 : -1
48
+ return right - left || (a < b ? -1 : a > b ? 1 : 0)
49
+ })
50
+ const orderedIds = () => {
51
+ const ids = projectIds(rank, pins)
52
+ if (record?.manualSessionIds === undefined) return newestFirst(ids)
53
+ const members = new Set(ids), manual = new Set(record.manualSessionIds)
54
+ // New tasks lead; an existing manual sequence never follows message activity.
55
+ return [...newestFirst(ids.filter(id => !manual.has(id))), ...record.manualSessionIds.filter(id => members.has(id))]
56
+ }
57
+ async function refreshCreatedAt() {
58
+ const before = orderedIds()
59
+ try {
60
+ if (typeof listSessions === 'function' && pins.some(id => !headers.has(id))) {
61
+ const rows = await listSessions(lifetime.signal)
62
+ if (disposed) return
63
+ for (const { header } of rows) if (pins.includes(header.id)) {
64
+ headers.set(header.id, header)
65
+ if (!createdAt.has(header.id) && Number.isSafeInteger(header.createdAt) && header.createdAt >= 0) createdAt.set(header.id, header.createdAt)
66
+ }
67
+ }
68
+ for (const id of [...pins]) {
69
+ const header = headers.get(id)
70
+ // Forks inherit parent work; their own creation header remains the key.
71
+ if (!header || header.isSeeded === true) continue
72
+ let timestamp = firstWorkAt.get(id)
73
+ if (timestamp === undefined && typeof readTaskStartedAt === 'function') {
74
+ try { timestamp = await readTaskStartedAt(header, lifetime.signal) }
75
+ catch (error) { if (!disposed) warn(error); continue }
76
+ if (disposed) return
77
+ if (!pins.includes(id) || !Number.isSafeInteger(timestamp) || timestamp < 0) continue
78
+ firstWorkAt.set(id, timestamp); metadataDirty = true
79
+ // Only work born after the manual decision leads. A delayed birth
80
+ // read must not override an explicit drag made after that work began.
81
+ const manualAt = record?.manualOrderAt ?? Date.parse(record?.updatedAt)
82
+ const newerThanManual = Number.isFinite(manualAt) ? timestamp > manualAt : phase === 'done' || phase === 'partial'
83
+ if (newerThanManual && record?.manualSessionIds?.includes(id)) {
84
+ record = { ...record, manualSessionIds: record.manualSessionIds.filter(value => value !== id) }
85
+ }
86
+ }
87
+ if (timestamp !== undefined) createdAt.set(id, Math.max(createdAt.get(id) ?? 0, timestamp))
88
+ }
89
+ for (const map of [createdAt, firstWorkAt, headers]) for (const id of map.keys()) if (!pins.includes(id)) map.delete(id)
90
+ if (!sameIds(before, orderedIds())) { revision++; updatedAt = now() }
91
+ } catch (error) { if (!disposed) warn(error) }
92
+ }
93
+ const response = () => ({
94
+ queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
95
+ ...(warnings.length === 0 ? {} : { warnings: [...warnings] }),
96
+ migration: { status: phase }, writable: !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial'),
97
+ })
98
+ const nativeSnapshot = (nextPins, nextArchived, observe = true) => {
99
+ const p = ownedIds(nextPins), a = ownedIds(nextArchived)
100
+ nativeValid = true
101
+ if (sameIds(p, pins) && sameIds(a, archived)) return
102
+ if (observe) for (const id of [...changedIds(pins, p), ...changedIds(archived, a)]) touched.add(id)
103
+ const previous = projectIds(rank, pins)
104
+ // A newly admitted task goes first even if the native action reuses an old
105
+ // blank Session or its header is not in the metadata catalog yet. Only new
106
+ // membership counts; message/status updates and native pin-array order do not.
107
+ if (observe && phase !== 'running') {
108
+ const previousMembers = new Set(pins)
109
+ let admittedAt = taskCreationNow()
110
+ if (Number.isSafeInteger(admittedAt) && admittedAt >= 0) {
111
+ // A same-millisecond click must still precede the prior admission. This
112
+ // logical ordering key is never shown as a replacement Session date.
113
+ for (const timestamp of createdAt.values()) if (timestamp >= admittedAt && timestamp < Number.MAX_SAFE_INTEGER) admittedAt = timestamp + 1
114
+ for (const id of p) if (!previousMembers.has(id)) { createdAt.set(id, admittedAt); admittedHere.add(id) }
115
+ }
116
+ }
117
+ pins = p; archived = a; epoch++
118
+ if (!sameIds(previous, projectIds(rank, pins))) { revision++; updatedAt = now() }
119
+ scheduleReconcile()
120
+ }
121
+ function scheduleReconcile() {
122
+ if (!disposed && nativeValid && started && table !== undefined && (phase === 'done' || phase === 'partial') && !reconcileQueued) {
123
+ reconcileQueued = true
124
+ void enqueue(async () => { reconcileQueued = false; await refreshCreatedAt(); await reconcile() }).catch(error => { reconcileQueued = false; warn(error) })
125
+ }
126
+ }
127
+ // Storage emits before WorkspaceRegistry's private cache is assigned. Payload
128
+ // ownership is essential: never synchronously read registry getters here.
129
+ const off = subscribe(change => {
130
+ if (disposed || change?.domain !== 'workspace' || change.table !== '' || change.key !== '' || change.operation !== 'put') return
131
+ try { nativeSnapshot(change.value?.pinnedSessionIds, change.value?.archivedSessionIds) }
132
+ catch (error) { nativeValid = false; warn(error) }
133
+ })
134
+ try { nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds, false) }
135
+ catch (error) { nativeValid = false; warn(error); phase = 'error' }
136
+ const initiallyPinned = new Set(pins)
137
+
138
+ async function save(next) {
139
+ const nextRank = ownedIds(next.sessionIds)
140
+ const nextRevision = Math.max(revision, record?.revision ?? 0) + (sameIds(rank, nextRank) ? 0 : 1)
141
+ const value = { ...next, schemaVersion: 1, revision: nextRevision, sessionIds: nextRank, updatedAt: now(),
142
+ taskCreatedAtBySession: Object.fromEntries([...createdAt].filter(([id]) => pins.includes(id))),
143
+ taskFirstWorkAtBySession: Object.fromEntries([...firstWorkAt].filter(([id]) => pins.includes(id))),
144
+ ...(next.manualSessionIds === undefined ? {} : { manualSessionIds: next.manualSessionIds.filter(id => pins.includes(id)) }) }
145
+ // A singleton owner plus this controller's tail owns all writes to this domain.
146
+ if (record === undefined) await table.put(NATIVE_ORDER_KEY, value)
147
+ else await table.update(NATIVE_ORDER_KEY, () => value)
148
+ metadataDirty = false
149
+ record = value; rank = [...value.sessionIds]; revision = Math.max(revision, value.revision); updatedAt = value.updatedAt
150
+ return value
151
+ }
152
+ async function reconcile() {
153
+ if (record === undefined || phase === 'initializing' || phase === 'running') return
154
+ const ids = orderedIds()
155
+ if (sameIds(ids, rank) && record.revision === revision && !metadataDirty) return
156
+ await save({ ...record, sessionIds: ids })
157
+ }
158
+ async function open(factory) {
159
+ const handle = await factory()
160
+ if (disposed) { await handle.close(); throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503) }
161
+ handles.add(handle)
162
+ return handle
163
+ }
164
+ async function close(handle) { if (!handles.delete(handle)) return; await handle.close() }
165
+ const replaceEntry = (id, patch) => ({ ...record, migration: { ...record.migration,
166
+ entries: record.migration.entries.map(entry => entry.sessionId === id ? { ...entry, ...patch } : entry) } })
167
+
168
+ async function runMigration() {
169
+ for (const entry of record.migration.entries) {
170
+ if (disposed) return
171
+ if (entry.status !== 'pending') continue
172
+ await enqueue(async () => {
173
+ if (!nativeValid) throw new Error('Native pin state is unavailable; legacy import has stopped')
174
+ if (touched.has(entry.sessionId) || pins.includes(entry.sessionId) || archived.includes(entry.sessionId)) {
175
+ await save(replaceEntry(entry.sessionId, { status: 'skipped', detail: 'Native pin/archive activity takes precedence' })); return
176
+ }
177
+ // Durable BEFORE the side effect. An interrupted journal never retries.
178
+ await save(replaceEntry(entry.sessionId, { status: 'attempted' }))
179
+ if (disposed) return
180
+ if (!nativeValid) throw new Error('Native pin state became unavailable before legacy import')
181
+ if (touched.has(entry.sessionId) || pins.includes(entry.sessionId) || archived.includes(entry.sessionId)) {
182
+ await save(replaceEntry(entry.sessionId, { status: 'skipped', detail: 'Native activity while import was preparing' })); return
183
+ }
184
+ const observedEpoch = epoch
185
+ let outcome
186
+ try {
187
+ await registry.pinSession(entry.sessionId)
188
+ if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
189
+ outcome = { status: 'imported' }
190
+ } catch (error) {
191
+ const known = error?.name === 'WorkspaceUnknownSessionError' || error?.name === 'WorkspaceArchivedSessionPinError'
192
+ outcome = { status: known ? 'skipped' : 'failed', detail: messageOf(error) }
193
+ warn(`Legacy task ${entry.sessionId} was not imported: ${outcome.detail}`)
194
+ }
195
+ await save(replaceEntry(entry.sessionId, outcome))
196
+ })
197
+ }
198
+ if (disposed) return
199
+ await enqueue(async () => {
200
+ const partial = record.migration.entries.some(entry => entry.status === 'failed' || entry.status === 'attempted')
201
+ const status = partial ? 'partial' : 'done'
202
+ // Until now legacy seed rank was retained even for not-yet-imported ids.
203
+ await refreshCreatedAt()
204
+ await save({ ...record, sessionIds: orderedIds(), migration: { ...record.migration, status } })
205
+ phase = status
206
+ })
207
+ }
208
+ async function initialize() {
209
+ if (!nativeValid) throw new Error('Native pin state is unavailable')
210
+ const order = await open(openOrder); table = order.table
211
+ const stored = table.get(NATIVE_ORDER_KEY)
212
+ if (stored !== undefined) {
213
+ rank = ownedIds(stored.sessionIds); record = stored; revision = Math.max(revision, stored.revision); updatedAt = stored.updatedAt
214
+ for (const [id, timestamp] of Object.entries(stored.taskCreatedAtBySession ?? {})) {
215
+ if (pins.includes(id) && !admittedHere.has(id) && Number.isSafeInteger(timestamp) && timestamp >= 0) createdAt.set(id, timestamp)
216
+ }
217
+ for (const [id, timestamp] of Object.entries(stored.taskFirstWorkAtBySession ?? {})) {
218
+ if (pins.includes(id) && Number.isSafeInteger(timestamp) && timestamp >= 0) firstWorkAt.set(id, timestamp)
219
+ }
220
+ await refreshCreatedAt()
221
+ for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn(`Legacy task ${entry.sessionId}: ${entry.detail}`)
222
+ if (stored.migration.status === 'running') {
223
+ warn('Legacy pin migration was interrupted; unconfirmed entries will not be retried automatically. The original queue is preserved.')
224
+ await enqueue(async () => {
225
+ await save({ ...record, sessionIds: projectIds(rank, pins), 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) } })
226
+ })
227
+ phase = 'partial'
228
+ } else phase = stored.migration.status
229
+ await enqueue(reconcile)
230
+ return
231
+ }
232
+ await refreshCreatedAt()
233
+ // The legacy unit is opened with its unchanged v1 schema, read, then closed.
234
+ const legacy = await open(openLegacy)
235
+ let seed
236
+ try { seed = legacy.table.get(NATIVE_ORDER_KEY); if (seed !== undefined) ownedIds(seed.sessionIds) }
237
+ finally { await close(legacy) }
238
+ const seedIds = seed?.sessionIds ?? [], alreadyNative = new Set([...initiallyPinned, ...pins])
239
+ const entries = seedIds.map(sessionId => ({ sessionId,
240
+ status: alreadyNative.has(sessionId) || touched.has(sessionId) || archived.includes(sessionId) ? 'skipped' : 'pending',
241
+ ...(alreadyNative.has(sessionId) ? { detail: 'Already officially pinned; never an import target' } : {}),
242
+ }))
243
+ await enqueue(async () => {
244
+ await save({ schemaVersion: 1, revision, sessionIds: [...seedIds, ...pins.filter(id => !seedIds.includes(id))], updatedAt: now(),
245
+ migration: { status: 'running', sourceRevision: seed?.revision ?? null, entries } })
246
+ phase = 'running'
247
+ })
248
+ migration = runMigration().catch(error => { if (!disposed) { warn(`Legacy migration stopped: ${messageOf(error)}. The original queue is preserved.`); phase = 'error' } })
249
+ }
250
+ const start = () => {
251
+ if (started || disposed) return
252
+ started = true; phase = 'initializing'
253
+ initialization = initialize().catch(error => { if (!disposed) { phase = 'error'; warn(`Task order initialization failed: ${messageOf(error)}`) } })
254
+ }
255
+ function requireCommand(command) {
256
+ if (command === null || typeof command !== 'object' || typeof command.sessionId !== 'string' || command.sessionId === '') throw new NativePinError('better-tasks/invalid-request', 'A Session identity is required')
257
+ if (!['move', 'pin', 'pin-created', 'unpin'].includes(command.action)) throw new NativePinError('better-tasks/invalid-request', 'Unsupported task operation')
258
+ }
259
+ return {
260
+ read() { start(); scheduleReconcile(); return response() },
261
+ current: response,
262
+ async mutate(command) {
263
+ requireCommand(command)
264
+ // Explicit compatibility intent also counts when the native call will be
265
+ // an idempotent no-op and therefore emit no workspace change event.
266
+ if (command.action !== 'move') touched.add(command.sessionId)
267
+ start()
268
+ // Do not queue a move behind an in-flight migration I/O operation merely
269
+ // to return the already-known initializing/readonly state afterwards.
270
+ if (command.action === 'move' && !response().writable) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is initializing, migrating or unavailable; official Pin remains usable', 503)
271
+ return enqueue(async () => {
272
+ if (command.action !== 'move') {
273
+ // Compatibility for old clients; all new UI uses official public controls.
274
+ const observedEpoch = epoch
275
+ if (command.action === 'unpin') await registry.unpinSession(command.sessionId)
276
+ else await registry.pinSession(command.sessionId)
277
+ if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
278
+ try { await reconcile() } catch (error) { warn(`Native pin change succeeded, but task order could not be saved: ${messageOf(error)}`) }
279
+ return response()
280
+ }
281
+ if (!Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0) throw new NativePinError('better-tasks/invalid-request', 'expectedRevision must be a non-negative safe integer')
282
+ if (!response().writable) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is initializing, migrating or unavailable; official Pin remains usable', 503)
283
+ await reconcile()
284
+ if (command.expectedRevision !== revision) throw new NativePinError('better-tasks/revision-conflict', 'Task order changed', 409)
285
+ const ids = orderedIds(), before = command.beforeSessionId ?? null
286
+ if (!ids.includes(command.sessionId)) throw new NativePinError('better-tasks/not-pinned', 'Session is no longer officially pinned', 409)
287
+ 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)
288
+ if (before === command.sessionId) return response()
289
+ const next = ids.filter(id => id !== command.sessionId)
290
+ next.splice(before === null ? next.length : next.indexOf(before), 0, command.sessionId)
291
+ if (sameIds(next, ids)) return response()
292
+ const observedEpoch = epoch
293
+ await save({ ...record, sessionIds: next, manualSessionIds: next, manualOrderAt: taskCreationNow() })
294
+ if (observedEpoch !== epoch) {
295
+ await reconcile()
296
+ throw new NativePinError('better-tasks/revision-conflict', 'Official membership changed while saving task order', 409)
297
+ }
298
+ return response()
299
+ })
300
+ },
301
+ async settled() { await initialization; await migration; await tail },
302
+ async dispose() {
303
+ if (disposed) return
304
+ disposed = true; lifetime.abort(); off?.()
305
+ await initialization; await migration; await tail
306
+ await Promise.all([...handles].map(close))
307
+ },
308
+ }
309
+ }