@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,193 @@
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
+ const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
71
+ const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
72
+ if (!started || disposed || !(restoring || saving)) { polls = 0; return }
73
+ if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
74
+ polls++
75
+ timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
76
+ }
77
+ const adopt = payload => {
78
+ const incoming = ownedQueue(payload.queue)
79
+ const initializing = payload.migration?.status === 'initializing'
80
+ if (!initializing || !hasStableRank) rank = incoming
81
+ if (!initializing) hasStableRank = true
82
+ loaded = true; writable = payload.writable !== false
83
+ warnings = Array.isArray(payload.warnings) ? payload.warnings.filter(x => typeof x === 'string') : []
84
+ migration = payload.migration ?? null
85
+ orderError = null
86
+ publish(); armMigrationRefresh()
87
+ return source.getSnapshot().queue
88
+ }
89
+ const readOrder = async () => {
90
+ const token = generation
91
+ try {
92
+ const { response, payload } = await request('GET', undefined, lifetime.signal)
93
+ if (disposed || token !== generation) return source.getSnapshot().queue
94
+ if (!response.ok) throw new Error(payload.message ?? `Task order read failed (${response.status})`)
95
+ return adopt(payload)
96
+ } catch (error) {
97
+ if (!disposed && token === generation) { orderError = String(error?.message ?? error); writable = false; publish() }
98
+ throw error
99
+ }
100
+ }
101
+ const refresh = () => {
102
+ if (disposed) return Promise.reject(new Error('Task pins are disposed'))
103
+ if (binding === undefined) return Promise.resolve(source.getSnapshot().queue)
104
+ if (refreshPromise !== null) { refreshAgain = true; return refreshPromise }
105
+ refreshPromise = enqueue(readOrder).finally(() => {
106
+ refreshPromise = null
107
+ if (refreshAgain && !disposed && started && binding !== undefined) { refreshAgain = false; void refresh().catch(() => {}) }
108
+ })
109
+ return refreshPromise
110
+ }
111
+ const connectNative = (next) => {
112
+ disconnect?.()
113
+ if (disposed) return () => {}
114
+ if (next?.source?.getSnapshot === undefined || typeof next?.source?.subscribe !== 'function' ||
115
+ typeof next.pinSession !== 'function' || typeof next.unpinSession !== 'function') {
116
+ orderError = 'Native Pin service is unavailable'; publish(); return () => {}
117
+ }
118
+ binding = next; generation++
119
+ let signature, closed = false
120
+ const observe = () => {
121
+ if (closed || disposed) return
122
+ const snapshot = next.source.getSnapshot()
123
+ native = { ...snapshot, pinnedSessionIds: [...(snapshot.pinnedSessionIds ?? [])], archivedSessionIds: [...(snapshot.archivedSessionIds ?? [])] }
124
+ const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
125
+ const changed = signature !== value
126
+ signature = value; publish()
127
+ if (changed && nativeReady() && started) void refresh().catch(() => {})
128
+ }
129
+ const off = next.source.subscribe(observe)
130
+ disconnect = () => {
131
+ if (closed) return
132
+ closed = true; off(); stopTimer(); generation++
133
+ if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
134
+ }
135
+ observe()
136
+ return disconnect
137
+ }
138
+ const setPinned = (sessionId, pinned) => {
139
+ if (disposed || binding === undefined) return Promise.reject(new Error('Native Pin service is unavailable'))
140
+ const prior = pinFlights.get(sessionId)
141
+ if (prior?.pinned === pinned) return prior.promise
142
+ if (!pending.has(sessionId) && nativeReady() && source.getSnapshot().queue.sessionIds.includes(sessionId) === pinned) {
143
+ return Promise.resolve(source.getSnapshot().queue)
144
+ }
145
+ const target = binding
146
+ actionError = null; countPending(sessionId, 1)
147
+ const promise = Promise.resolve().then(() => (pinned ? target.pinSession : target.unpinSession)(sessionId)).then(() => {
148
+ // Native model/follow owns the result. Never force the requested boolean or
149
+ // retry the Pin just because the separate rank service is unavailable.
150
+ publish(); if (started) void refresh().catch(() => {})
151
+ return source.getSnapshot().queue
152
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => {
153
+ if (pinFlights.get(sessionId)?.promise === promise) pinFlights.delete(sessionId)
154
+ countPending(sessionId, -1)
155
+ })
156
+ pinFlights.set(sessionId, { pinned, promise })
157
+ return promise
158
+ }
159
+ const moveBefore = (sessionId, beforeSessionId) => {
160
+ actionError = null; countPending(sessionId, 1)
161
+ return enqueue(async () => {
162
+ if (!loaded || !writable) await readOrder()
163
+ if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
164
+ for (let attempt = 0; attempt < 2; attempt++) {
165
+ const queue = source.getSnapshot().queue
166
+ if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
167
+ const { response, payload } = await request('POST', {
168
+ action: 'move', sessionId, beforeSessionId: beforeSessionId ?? null, expectedRevision: rank.revision,
169
+ }, lifetime.signal)
170
+ if (disposed) throw new Error('Task pins are disposed')
171
+ if (response.ok) return adopt(payload)
172
+ const hasCurrentOrder = validQueue(payload.queue)
173
+ if (hasCurrentOrder) adopt(payload)
174
+ if (response.status === 409 && hasCurrentOrder) { if (!writable) break; continue }
175
+ throw new Error(payload.message ?? `Task order write failed (${response.status})`)
176
+ }
177
+ throw new Error('Task order changed again; retry the move')
178
+ }).catch(error => { actionError = String(error?.message ?? error); publish(); throw error }).finally(() => countPending(sessionId, -1))
179
+ }
180
+ return {
181
+ source, connectNative, refresh, setPinned, pinCreated: sessionId => setPinned(sessionId, true), moveBefore,
182
+ start() {
183
+ started = true
184
+ const onFocus = () => { polls = 0; void refresh().catch(() => {}) }
185
+ if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
186
+ if (binding !== undefined && nativeReady()) void refresh().catch(() => {})
187
+ return () => {
188
+ disposed = true; started = false; stopTimer(); disconnect?.(); lifetime.abort()
189
+ if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
190
+ }
191
+ },
192
+ }
193
+ }
@@ -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
+ }