@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,204 @@
1
+ const EMPTY_QUEUE = Object.freeze({ schemaVersion: 1, revision: 0, sessionIds: Object.freeze([]), updatedAt: '' })
2
+
3
+ function validQueue(value) {
4
+ return value !== null && typeof value === 'object' && value.schemaVersion === 1 &&
5
+ Number.isSafeInteger(value.revision) && value.revision >= 0 && Array.isArray(value.sessionIds) &&
6
+ value.sessionIds.every(id => typeof id === 'string' && id !== '') &&
7
+ new Set(value.sessionIds).size === value.sessionIds.length && typeof value.updatedAt === 'string'
8
+ }
9
+ function ownedQueue(value) {
10
+ if (!validQueue(value)) throw new Error('better-tasks: Host returned an invalid task order')
11
+ return Object.freeze({ ...value, sessionIds: Object.freeze([...value.sessionIds]) })
12
+ }
13
+ function createClientSource(initial) {
14
+ let snapshot = initial
15
+ const listeners = new Set()
16
+ return { getSnapshot: () => snapshot, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
17
+ set(next) { snapshot = next; for (const fn of [...listeners]) fn() } }
18
+ }
19
+
20
+ /** Rank is metadata only: stale rank can never resurrect a native-unpinned Session. */
21
+ export function projectNativeTaskQueue(rank, native) {
22
+ const archived = new Set(native?.archivedSessionIds ?? [])
23
+ const pins = [...new Set(native?.pinnedSessionIds ?? [])].filter(id => !archived.has(id))
24
+ const members = new Set(pins), ranked = new Set(rank.sessionIds)
25
+ return Object.freeze({ ...rank, sessionIds: Object.freeze([
26
+ ...pins.filter(id => !ranked.has(id)), ...rank.sessionIds.filter(id => members.has(id)),
27
+ ]) })
28
+ }
29
+ async function pinRequest(method, body, signal) {
30
+ const response = await fetch('/api/better-tasks/pins', { method, signal,
31
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
32
+ body: body === undefined ? undefined : JSON.stringify(body) })
33
+ let payload
34
+ try { payload = await response.json() } catch { payload = {} }
35
+ return { response, payload }
36
+ }
37
+
38
+ /** Observe official membership; own only rank refresh, pending feedback and errors. */
39
+ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeout, cancel = clearTimeout } = {}) {
40
+ const source = createClientSource(Object.freeze({ phase: 'loading', queue: EMPTY_QUEUE,
41
+ pendingSessionIds: Object.freeze([]), error: null, writable: false, migration: null }))
42
+ const pending = new Map(), pinFlights = new Map()
43
+ const lifetime = new AbortController()
44
+ let rank = EMPTY_QUEUE, native, binding, disconnect, tail = Promise.resolve()
45
+ let started = false, disposed = false, generation = 0, loaded = false, writable = false, hasStableRank = false
46
+ let orderError = null, actionError = null, warnings = [], migration = null
47
+ let refreshPromise = null, refreshAgain = false, timer, polls = 0
48
+ const nativeReady = () => native?.phase === 'ready'
49
+ const publish = () => {
50
+ if (disposed) return
51
+ const nativeError = native?.error?.message ?? (native?.state === 'error' ? 'Native Pin service is unavailable' : null)
52
+ source.set(Object.freeze({ phase: nativeReady() ? 'ready' : 'loading',
53
+ queue: projectNativeTaskQueue(rank, native), pendingSessionIds: Object.freeze([...pending.keys()]),
54
+ error: actionError ?? orderError ?? nativeError ?? (warnings.length ? warnings.join('; ') : null),
55
+ writable: nativeReady() && loaded && writable, migration: migration?.status ?? null }))
56
+ }
57
+ const countPending = (id, delta) => {
58
+ const count = (pending.get(id) ?? 0) + delta
59
+ if (count > 0) pending.set(id, count); else pending.delete(id)
60
+ publish()
61
+ }
62
+ const enqueue = work => {
63
+ const next = tail.then(() => { if (disposed) throw new Error('Task pins are disposed'); return work() })
64
+ tail = next.catch(() => {})
65
+ return next
66
+ }
67
+ const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
68
+ const armMigrationRefresh = () => {
69
+ stopTimer()
70
+ if (!started || disposed || !['pending', 'initializing', 'running'].includes(migration?.status)) { polls = 0; return }
71
+ if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
72
+ polls++
73
+ timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
74
+ }
75
+ const adopt = payload => {
76
+ const incoming = ownedQueue(payload.queue)
77
+ const initializing = payload.migration?.status === 'initializing'
78
+ if (!initializing || !hasStableRank) rank = incoming
79
+ if (!initializing) hasStableRank = true
80
+ loaded = true; writable = payload.writable !== false
81
+ warnings = Array.isArray(payload.warnings) ? payload.warnings.filter(x => typeof x === 'string') : []
82
+ migration = payload.migration ?? null
83
+ orderError = null
84
+ publish(); armMigrationRefresh()
85
+ return source.getSnapshot().queue
86
+ }
87
+ const readOrder = async () => {
88
+ const token = generation
89
+ try {
90
+ const { response, payload } = await request('GET', undefined, lifetime.signal)
91
+ if (disposed || token !== generation) return source.getSnapshot().queue
92
+ if (!response.ok) throw new Error(payload.message ?? `Task order read failed (${response.status})`)
93
+ return adopt(payload)
94
+ } catch (error) {
95
+ if (!disposed && token === generation) { orderError = String(error?.message ?? error); writable = false; publish() }
96
+ throw error
97
+ }
98
+ }
99
+ const refresh = () => {
100
+ if (disposed) return Promise.reject(new Error('Task pins are disposed'))
101
+ if (binding === undefined) return Promise.resolve(source.getSnapshot().queue)
102
+ if (refreshPromise !== null) { refreshAgain = true; return refreshPromise }
103
+ refreshPromise = enqueue(readOrder).finally(() => {
104
+ refreshPromise = null
105
+ if (refreshAgain && !disposed && started && binding !== undefined) { refreshAgain = false; void refresh().catch(() => {}) }
106
+ })
107
+ return refreshPromise
108
+ }
109
+ const connectNative = (next) => {
110
+ disconnect?.()
111
+ if (disposed) return () => {}
112
+ if (next?.source?.getSnapshot === undefined || typeof next?.source?.subscribe !== 'function' ||
113
+ typeof next.pinSession !== 'function' || typeof next.unpinSession !== 'function') {
114
+ orderError = 'Native Pin service is unavailable'; publish(); return () => {}
115
+ }
116
+ binding = next; generation++
117
+ let signature, closed = false
118
+ let visible = new Set()
119
+ const observeStartedTasks = () => {
120
+ if (closed || disposed || !nativeReady()) return
121
+ const sessions = next.sessions?.getSnapshot?.()
122
+ if (sessions?.phase !== 'ready') return
123
+ const nextVisible = new Set(native.pinnedSessionIds.filter(id => sessions.byId?.[id]?.blank === false))
124
+ const appeared = [...nextVisible].some(id => !visible.has(id))
125
+ visible = nextVisible
126
+ // A pre-existing Pin can become a new task without a workspace change.
127
+ if (appeared && started) void refresh().catch(() => {})
128
+ }
129
+ const observe = () => {
130
+ if (closed || disposed) return
131
+ const snapshot = next.source.getSnapshot()
132
+ native = { ...snapshot, pinnedSessionIds: [...(snapshot.pinnedSessionIds ?? [])], archivedSessionIds: [...(snapshot.archivedSessionIds ?? [])] }
133
+ const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
134
+ const changed = signature !== value
135
+ signature = value; publish()
136
+ observeStartedTasks()
137
+ if (changed && nativeReady() && started) void refresh().catch(() => {})
138
+ }
139
+ const off = next.source.subscribe(observe)
140
+ const offSessions = typeof next.sessions?.subscribe === 'function' ? next.sessions.subscribe(observeStartedTasks) : () => {}
141
+ disconnect = () => {
142
+ if (closed) return
143
+ closed = true; off(); offSessions(); stopTimer(); generation++
144
+ if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
145
+ }
146
+ observe()
147
+ return disconnect
148
+ }
149
+ const setPinned = (sessionId, pinned) => {
150
+ if (disposed || binding === undefined) return Promise.reject(new Error('Native Pin service is unavailable'))
151
+ const prior = pinFlights.get(sessionId)
152
+ if (prior?.pinned === pinned) return prior.promise
153
+ if (!pending.has(sessionId) && nativeReady() && source.getSnapshot().queue.sessionIds.includes(sessionId) === pinned) {
154
+ return Promise.resolve(source.getSnapshot().queue)
155
+ }
156
+ const target = binding
157
+ actionError = null; countPending(sessionId, 1)
158
+ const promise = Promise.resolve().then(() => (pinned ? target.pinSession : target.unpinSession)(sessionId)).then(() => {
159
+ // Native model/follow owns the result. Never force the requested boolean or
160
+ // retry the Pin just because the separate rank service is unavailable.
161
+ publish(); if (started) void refresh().catch(() => {})
162
+ return source.getSnapshot().queue
163
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => {
164
+ if (pinFlights.get(sessionId)?.promise === promise) pinFlights.delete(sessionId)
165
+ countPending(sessionId, -1)
166
+ })
167
+ pinFlights.set(sessionId, { pinned, promise })
168
+ return promise
169
+ }
170
+ const moveBefore = (sessionId, beforeSessionId) => {
171
+ actionError = null; countPending(sessionId, 1)
172
+ return enqueue(async () => {
173
+ if (!loaded || !writable) await readOrder()
174
+ if (!writable) throw new Error('Task order is not ready; pin migration may still be running')
175
+ for (let attempt = 0; attempt < 2; attempt++) {
176
+ const queue = source.getSnapshot().queue
177
+ if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
178
+ const { response, payload } = await request('POST', {
179
+ action: 'move', sessionId, beforeSessionId: beforeSessionId ?? null, expectedRevision: rank.revision,
180
+ }, lifetime.signal)
181
+ if (disposed) throw new Error('Task pins are disposed')
182
+ if (response.ok) return adopt(payload)
183
+ const hasCurrentOrder = validQueue(payload.queue)
184
+ if (hasCurrentOrder) adopt(payload)
185
+ if (response.status === 409 && hasCurrentOrder) { if (!writable) break; continue }
186
+ throw new Error(payload.message ?? `Task order write failed (${response.status})`)
187
+ }
188
+ throw new Error('Task order changed again; retry the move')
189
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => countPending(sessionId, -1))
190
+ }
191
+ return {
192
+ source, connectNative, refresh, setPinned, pinCreated: sessionId => setPinned(sessionId, true), moveBefore,
193
+ start() {
194
+ started = true
195
+ const onFocus = () => { polls = 0; void refresh().catch(() => {}) }
196
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
197
+ if (binding !== undefined && nativeReady()) void refresh().catch(() => {})
198
+ return () => {
199
+ disposed = true; started = false; stopTimer(); disconnect?.(); lifetime.abort()
200
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
201
+ }
202
+ },
203
+ }
204
+ }
@@ -0,0 +1,118 @@
1
+ export const PIN_QUEUE_SCHEMA_VERSION = 1
2
+ export const PIN_QUEUE_KEY = 'global'
3
+
4
+ export class PinQueueError extends Error {
5
+ constructor(code, message, details = undefined) {
6
+ super(message)
7
+ this.name = 'PinQueueError'
8
+ this.code = code
9
+ if (details !== undefined) this.details = details
10
+ }
11
+ }
12
+
13
+ export function createEmptyPinQueue(now = new Date().toISOString()) {
14
+ return Object.freeze({
15
+ schemaVersion: PIN_QUEUE_SCHEMA_VERSION,
16
+ revision: 0,
17
+ sessionIds: Object.freeze([]),
18
+ updatedAt: now,
19
+ })
20
+ }
21
+
22
+ function requireRecord(record) {
23
+ if (record === null || typeof record !== 'object' || record.schemaVersion !== PIN_QUEUE_SCHEMA_VERSION) {
24
+ throw new PinQueueError('better-tasks/invalid-record', 'pin queue record is invalid')
25
+ }
26
+ if (!Number.isSafeInteger(record.revision) || record.revision < 0 || !Array.isArray(record.sessionIds)) {
27
+ throw new PinQueueError('better-tasks/invalid-record', 'pin queue record is invalid')
28
+ }
29
+ return record
30
+ }
31
+
32
+ function requireCommand(command) {
33
+ if (command === null || typeof command !== 'object') {
34
+ throw new PinQueueError('better-tasks/invalid-request', 'pin command must be an object')
35
+ }
36
+ if (!Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0) {
37
+ throw new PinQueueError('better-tasks/invalid-request', 'expectedRevision must be a non-negative safe integer')
38
+ }
39
+ if (!['pin', 'pin-created', 'unpin', 'move'].includes(command.action)) {
40
+ throw new PinQueueError('better-tasks/invalid-request', 'action must be pin, pin-created, unpin, or move')
41
+ }
42
+ if (typeof command.sessionId !== 'string' || command.sessionId.length === 0) {
43
+ throw new PinQueueError('better-tasks/invalid-request', 'sessionId must be a non-empty string')
44
+ }
45
+ if (command.beforeSessionId !== undefined && command.beforeSessionId !== null &&
46
+ (typeof command.beforeSessionId !== 'string' || command.beforeSessionId.length === 0)) {
47
+ throw new PinQueueError('better-tasks/invalid-request', 'beforeSessionId must be a non-empty string or null')
48
+ }
49
+ return command
50
+ }
51
+
52
+ function changedRecord(current, sessionIds, now) {
53
+ return Object.freeze({
54
+ schemaVersion: PIN_QUEUE_SCHEMA_VERSION,
55
+ revision: current.revision + 1,
56
+ sessionIds: Object.freeze(sessionIds),
57
+ updatedAt: now,
58
+ })
59
+ }
60
+
61
+ export function applyPinCommand(record, input, now = new Date().toISOString()) {
62
+ const current = requireRecord(record)
63
+ const command = requireCommand(input)
64
+ if (command.expectedRevision !== current.revision) {
65
+ throw new PinQueueError('better-tasks/revision-conflict', 'pin queue revision changed', {
66
+ expectedRevision: command.expectedRevision,
67
+ actualRevision: current.revision,
68
+ })
69
+ }
70
+
71
+ const sessionIds = [...current.sessionIds]
72
+ const index = sessionIds.indexOf(command.sessionId)
73
+ if (command.action === 'pin' || command.action === 'pin-created') {
74
+ if (index !== -1) return current
75
+ return changedRecord(current, [...sessionIds, command.sessionId], now)
76
+ }
77
+ if (command.action === 'unpin') {
78
+ if (index === -1) return current
79
+ sessionIds.splice(index, 1)
80
+ return changedRecord(current, sessionIds, now)
81
+ }
82
+ if (index === -1) {
83
+ throw new PinQueueError('better-tasks/not-pinned', `session is not pinned: ${command.sessionId}`)
84
+ }
85
+
86
+ const without = sessionIds.filter((id) => id !== command.sessionId)
87
+ const before = command.beforeSessionId ?? null
88
+ if (before === command.sessionId) return current
89
+ if (before === null) {
90
+ without.push(command.sessionId)
91
+ } else {
92
+ const beforeIndex = without.indexOf(before)
93
+ if (beforeIndex === -1) {
94
+ throw new PinQueueError('better-tasks/anchor-not-pinned', `move anchor is not pinned: ${before}`)
95
+ }
96
+ without.splice(beforeIndex, 0, command.sessionId)
97
+ }
98
+ if (without.every((id, nextIndex) => id === sessionIds[nextIndex])) return current
99
+ return changedRecord(current, without, now)
100
+ }
101
+
102
+ export function prunePinQueue(record, eligibleSessionIds, now = new Date().toISOString()) {
103
+ const current = requireRecord(record)
104
+ const eligible = eligibleSessionIds instanceof Set ? eligibleSessionIds : new Set(eligibleSessionIds)
105
+ const sessionIds = current.sessionIds.filter((id) => eligible.has(id))
106
+ if (sessionIds.length === current.sessionIds.length) return current
107
+ return changedRecord(current, sessionIds, now)
108
+ }
109
+
110
+ export function publicPinQueue(record) {
111
+ const current = requireRecord(record)
112
+ return {
113
+ schemaVersion: current.schemaVersion,
114
+ revision: current.revision,
115
+ sessionIds: [...current.sessionIds],
116
+ updatedAt: current.updatedAt,
117
+ }
118
+ }
@@ -0,0 +1,147 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { z } from 'zod'
3
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
4
+ import { PROJECT_ICON_CATALOG, ICON_KEYS, CATALOG_REVISION } from './project-icon-catalog.mjs'
5
+
6
+ export { ICON_KEYS }
7
+ export const COLOR_KEYS = Object.freeze([
8
+ 'blue', 'cyan', 'green', 'orange', 'pink', 'purple', 'red', 'yellow',
9
+ 'teal', 'indigo', 'amber', 'slate', 'black',
10
+ ])
11
+
12
+ const iconKey = z.enum(ICON_KEYS)
13
+ const colorKey = z.enum(COLOR_KEYS)
14
+ const appearanceRecord = z.object({
15
+ iconKey,
16
+ colorKey,
17
+ updatedAt: z.string(),
18
+ })
19
+
20
+ // Keep this exact name/version/table schema: it is the existing durable owner and
21
+ // allows Better Tasks to adopt the old package without migrating or copying data.
22
+ export const workspaceAppearanceDomain = defineDomain({
23
+ name: 'workspace_appearance',
24
+ version: 1,
25
+ tables: { appearances: domainTable(appearanceRecord) },
26
+ })
27
+
28
+ function appearanceError(code, message, details) {
29
+ const error = new Error(message)
30
+ error.code = code
31
+ if (details !== undefined) error.details = details
32
+ return error
33
+ }
34
+
35
+ function sendAppearanceJson(res, status, value) {
36
+ res.statusCode = status
37
+ res.setHeader('content-type', 'application/json; charset=utf-8')
38
+ res.setHeader('cache-control', 'no-store')
39
+ res.end(JSON.stringify(value))
40
+ }
41
+
42
+ async function readAppearanceJson(req) {
43
+ const chunks = []
44
+ let size = 0
45
+ for await (const chunk of req) {
46
+ size += chunk.length
47
+ if (size > 64 * 1024) throw appearanceError('project-appearance/request-too-large', 'request body exceeds 64 KiB')
48
+ chunks.push(chunk)
49
+ }
50
+ try {
51
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
52
+ } catch {
53
+ throw appearanceError('project-appearance/invalid-json', 'request body must be valid JSON')
54
+ }
55
+ }
56
+
57
+ export function createProjectAppearanceHttpHandler(registry) {
58
+ return async (req, res) => {
59
+ try {
60
+ if (req.method === 'GET') {
61
+ return sendAppearanceJson(res, 200, {
62
+ items: registry.list(),
63
+ iconKeys: ICON_KEYS,
64
+ colorKeys: COLOR_KEYS,
65
+ catalogRevision: CATALOG_REVISION,
66
+ icons: PROJECT_ICON_CATALOG,
67
+ })
68
+ }
69
+ if (req.method !== 'PUT') return sendAppearanceJson(res, 405, { code: 'method-not-allowed' })
70
+ const input = await readAppearanceJson(req)
71
+ const value = await registry.set(input.workspaceId, input)
72
+ return sendAppearanceJson(res, 200, { item: value })
73
+ } catch (error) {
74
+ const code = typeof error?.code === 'string' ? error.code : 'project-appearance/write-failed'
75
+ const status = code === 'project-appearance/workspace-not-found' ? 404 : code === 'project-appearance/request-too-large' ? 413 : 400
76
+ return sendAppearanceJson(res, status, { code, message: String(error?.message ?? error) })
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function installProjectAppearance(ctx) {
82
+ const domain = await ctx.storageDomain.open(workspaceAppearanceDomain)
83
+ const table = domain.table('appearances')
84
+ let closed = false
85
+
86
+ const workspaceIds = new Set(ctx.workspaceRegistry.list().map((workspace) => String(workspace.id)))
87
+ for (const workspaceId of table.keys()) {
88
+ if (!workspaceIds.has(String(workspaceId))) await table.delete(workspaceId)
89
+ }
90
+
91
+ const registry = Object.freeze({
92
+ iconKeys: ICON_KEYS,
93
+ colorKeys: COLOR_KEYS,
94
+ get(workspaceId) {
95
+ return table.get(String(workspaceId))
96
+ },
97
+ list() {
98
+ return [...table.entries()].map(([workspaceId, appearance]) => ({
99
+ workspaceId: String(workspaceId),
100
+ iconKey: appearance.iconKey,
101
+ colorKey: appearance.colorKey,
102
+ updatedAt: appearance.updatedAt,
103
+ }))
104
+ },
105
+ async set(workspaceId, next) {
106
+ const id = String(workspaceId)
107
+ if (ctx.workspaceRegistry.get(id) === undefined) {
108
+ throw appearanceError('project-appearance/workspace-not-found', `project-appearance/workspace-not-found: ${id}`)
109
+ }
110
+ const parsed = appearanceRecord.safeParse({
111
+ iconKey: next?.iconKey,
112
+ colorKey: next?.colorKey,
113
+ updatedAt: new Date().toISOString(),
114
+ })
115
+ if (!parsed.success) {
116
+ throw appearanceError('project-appearance/invalid-appearance', 'project-appearance/invalid-appearance', {
117
+ iconKey: next?.iconKey,
118
+ colorKey: next?.colorKey,
119
+ })
120
+ }
121
+ await table.put(id, parsed.data)
122
+ return { workspaceId: id, ...parsed.data }
123
+ },
124
+ async remove(workspaceId) {
125
+ return table.delete(String(workspaceId))
126
+ },
127
+ })
128
+
129
+ ctx.provide('projectAppearanceRegistry', registry)
130
+ ctx.effect(() => ctx.webServer.register({
131
+ kind: 'exact',
132
+ path: '/api/project-appearance',
133
+ handler: createProjectAppearanceHttpHandler(registry),
134
+ }), 'dsh-better-tasks.project-appearance-http')
135
+ ctx.on('domain/changed', (change) => {
136
+ if (closed || change.domain !== 'workspace' || change.table !== 'workspaces' || change.operation !== 'deleted') return
137
+ registry.remove(change.key).catch((error) => {
138
+ ctx.logger.warn(`project appearance cleanup failed for deleted workspace '${change.key}': ${String(error)}`)
139
+ })
140
+ })
141
+ ctx.effect(() => async () => {
142
+ closed = true
143
+ await domain.close()
144
+ }, 'dsh-better-tasks.project-appearance-domain-close')
145
+
146
+ return registry
147
+ }