@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,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
|
+
}
|