@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.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/cordis.patch.yml +32 -0
- package/docs/dsh-0.2.2-rc.3-support.md +68 -0
- package/docs/install-rc3.md +94 -0
- package/docs/install.md +156 -0
- package/docs/rc2-slash-submit-catch.md +58 -0
- package/docs/release.md +65 -0
- package/docs/rollback.md +78 -0
- package/docs/sidebar-layout.md +72 -0
- package/docs/task-session-activity.md +13 -0
- package/lib/client.js +3184 -0
- package/package.json +78 -4
- package/src/client.js +2355 -0
- package/src/final-message.mjs +75 -0
- package/src/index.js +222 -0
- package/src/native-pin-host.mjs +309 -0
- package/src/pin-client.mjs +204 -0
- package/src/pin-model.mjs +118 -0
- package/src/project-appearance.mjs +147 -0
- package/src/project-icon-catalog.mjs +4 -0
- package/src/skill-picker.mjs +26 -0
- package/src/task-actions.mjs +80 -0
- package/src/task-execution.mjs +37 -0
- package/src/task-input-surface.mjs +152 -0
- package/src/task-model.mjs +235 -0
- package/src/task-preferences.mjs +139 -0
|
@@ -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
|
+
}
|