dsh-taskboard 0.6.6 → 0.7.0
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/README.md +305 -288
- package/lib/client.js +985 -931
- package/lib/host/archive-sessions.js +30 -0
- package/lib/host/archive-sessions.js.map +1 -0
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/locale.js +17 -0
- package/lib/host/locale.js.map +1 -0
- package/lib/host/routes.js +132 -6
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +2 -0
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +4 -1
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/storage-queue.js +14 -0
- package/lib/host/storage-queue.js.map +1 -0
- package/lib/host/storage.js +249 -0
- package/lib/host/storage.js.map +1 -0
- package/lib/host/store.js +34 -7
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +73 -113
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +23 -8
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +83 -27
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/builtin-templates.js +155 -0
- package/lib/shared/builtin-templates.js.map +1 -0
- package/lib/shared/protocol.js +39 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +90 -89
- package/src/client/api.ts +35 -1
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskBoard.tsx +16 -11
- package/src/client/board/TaskDetail.tsx +186 -17
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/board/TemplateManager.tsx +22 -10
- package/src/client/controller.ts +67 -5
- package/src/client/i18n/en.ts +35 -3
- package/src/client/i18n/templates.ts +25 -0
- package/src/client/i18n/zh.ts +35 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +35 -2
- package/src/host/archive-sessions.ts +18 -0
- package/src/host/assets.ts +120 -0
- package/src/host/execution.ts +4 -1
- package/src/host/locale.ts +44 -0
- package/src/host/routes.ts +132 -7
- package/src/host/scheduler.ts +2 -0
- package/src/host/session-sync.ts +4 -1
- package/src/host/storage-queue.ts +10 -0
- package/src/host/storage.ts +212 -0
- package/src/host/store.ts +40 -12
- package/src/host/templates.ts +38 -66
- package/src/host/tools.ts +28 -11
- package/src/index.ts +88 -33
- package/src/shared/api.ts +32 -3
- package/src/shared/builtin-templates.ts +153 -0
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +9 -9
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Durable, content-addressed image attachments for task descriptions/comments. */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { StorageQueue } from './storage-queue.ts'
|
|
6
|
+
|
|
7
|
+
export const MAX_ASSET_BYTES = 5 * 1024 * 1024
|
|
8
|
+
export const MAX_ASSET_STORE_BYTES = 200 * 1024 * 1024
|
|
9
|
+
export const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
export type ImageKind = { extension: 'png' | 'jpg' | 'gif' | 'webp'; mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' }
|
|
12
|
+
export type StoredAsset = ImageKind & { id: string; name: string; size: number; url: string }
|
|
13
|
+
|
|
14
|
+
const ASSET_NAME_RE = /^([a-f0-9]{64})\.(png|jpg|gif|webp)$/
|
|
15
|
+
|
|
16
|
+
/** Detect supported images from magic bytes; request MIME and filenames are not trusted. */
|
|
17
|
+
export function detectImage(bytes: Uint8Array): ImageKind | undefined {
|
|
18
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
|
|
19
|
+
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
|
|
20
|
+
return { extension: 'png', mime: 'image/png' }
|
|
21
|
+
}
|
|
22
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
23
|
+
return { extension: 'jpg', mime: 'image/jpeg' }
|
|
24
|
+
}
|
|
25
|
+
if (bytes.length >= 6) {
|
|
26
|
+
const head = Buffer.from(bytes.subarray(0, 6)).toString('ascii')
|
|
27
|
+
if (head === 'GIF87a' || head === 'GIF89a') return { extension: 'gif', mime: 'image/gif' }
|
|
28
|
+
}
|
|
29
|
+
if (bytes.length >= 12 && Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF'
|
|
30
|
+
&& Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') {
|
|
31
|
+
return { extension: 'webp', mime: 'image/webp' }
|
|
32
|
+
}
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Files live outside the ledger so state/SSE/tool payloads only carry short Markdown URLs. */
|
|
37
|
+
export class AssetStore {
|
|
38
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
39
|
+
private root: string
|
|
40
|
+
|
|
41
|
+
constructor(root: string, private readonly now: () => number = () => Date.now(), private readonly storageQueue?: StorageQueue) { this.root = root }
|
|
42
|
+
|
|
43
|
+
/** Current absolute attachment-directory path. */
|
|
44
|
+
location(): string { return this.root }
|
|
45
|
+
|
|
46
|
+
/** Switch reads and future writes after the coordinator copied the directory. */
|
|
47
|
+
setLocation(root: string): void { this.root = root }
|
|
48
|
+
|
|
49
|
+
async put(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
|
|
50
|
+
const run = () => this.putSerial(bytes, declaredMime)
|
|
51
|
+
if (this.storageQueue !== undefined) return this.storageQueue.run(run)
|
|
52
|
+
return (this.queue = this.queue.then(run, run)) as Promise<StoredAsset>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async putSerial(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
|
|
56
|
+
if (bytes.length === 0 || bytes.length > MAX_ASSET_BYTES) {
|
|
57
|
+
throw new Error(`image must be 1..${MAX_ASSET_BYTES} bytes`)
|
|
58
|
+
}
|
|
59
|
+
const kind = detectImage(bytes)
|
|
60
|
+
if (kind === undefined) throw new Error('unsupported image; use PNG, JPEG, GIF, or WebP')
|
|
61
|
+
if (declaredMime !== undefined && declaredMime.toLowerCase() !== kind.mime) {
|
|
62
|
+
throw new Error(`image content does not match ${declaredMime}`)
|
|
63
|
+
}
|
|
64
|
+
const id = createHash('sha256').update(bytes).digest('hex')
|
|
65
|
+
const name = `${id}.${kind.extension}`
|
|
66
|
+
await mkdir(this.root, { recursive: true })
|
|
67
|
+
try {
|
|
68
|
+
const current = await stat(join(this.root, name))
|
|
69
|
+
if (current.isFile()) return { id, name, size: current.size, url: `/dsh-taskboard/assets/${name}`, ...kind }
|
|
70
|
+
} catch { /* new content */ }
|
|
71
|
+
|
|
72
|
+
let total = 0
|
|
73
|
+
for (const entry of await readdir(this.root, { withFileTypes: true })) {
|
|
74
|
+
if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name)) continue
|
|
75
|
+
try { total += (await stat(join(this.root, entry.name))).size } catch { /* concurrent cleanup */ }
|
|
76
|
+
}
|
|
77
|
+
if (total + bytes.length > MAX_ASSET_STORE_BYTES) throw new Error('image store quota exceeded')
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(join(this.root, name), bytes, { flag: 'wx' })
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
82
|
+
}
|
|
83
|
+
return { id, name, size: bytes.length, url: `/dsh-taskboard/assets/${name}`, ...kind }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async read(name: string): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> {
|
|
87
|
+
const run = async (): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> => {
|
|
88
|
+
const match = ASSET_NAME_RE.exec(name)
|
|
89
|
+
if (match === null) return undefined
|
|
90
|
+
const mime = match[2] === 'png' ? 'image/png'
|
|
91
|
+
: match[2] === 'jpg' ? 'image/jpeg'
|
|
92
|
+
: match[2] === 'gif' ? 'image/gif' : 'image/webp'
|
|
93
|
+
try {
|
|
94
|
+
return { bytes: await readFile(join(this.root, name)), mime }
|
|
95
|
+
} catch { return undefined }
|
|
96
|
+
}
|
|
97
|
+
return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove abandoned draft uploads after a grace period; referenced files always survive. */
|
|
101
|
+
async cleanup(referencedContent: string): Promise<number> {
|
|
102
|
+
const run = async (): Promise<number> => {
|
|
103
|
+
let entries
|
|
104
|
+
try { entries = await readdir(this.root, { withFileTypes: true }) } catch { return 0 }
|
|
105
|
+
let removed = 0
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name) || referencedContent.includes(entry.name)) continue
|
|
108
|
+
const path = join(this.root, entry.name)
|
|
109
|
+
try {
|
|
110
|
+
const info = await stat(path)
|
|
111
|
+
if (this.now() - info.mtimeMs < ORPHAN_GRACE_MS) continue
|
|
112
|
+
await rm(path, { force: true })
|
|
113
|
+
removed += 1
|
|
114
|
+
} catch { /* best effort */ }
|
|
115
|
+
}
|
|
116
|
+
return removed
|
|
117
|
+
}
|
|
118
|
+
return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/host/execution.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface ExecutionWorkspaceFace {
|
|
|
72
72
|
|
|
73
73
|
/** Narrow event-bus face for settlement listening. */
|
|
74
74
|
export interface EventsFace {
|
|
75
|
-
onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void): () => void
|
|
75
|
+
onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void | Promise<void>): () => void
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Everything the execution service needs. */
|
|
@@ -293,6 +293,8 @@ export class ExecutionService {
|
|
|
293
293
|
task.comments.push({
|
|
294
294
|
id: newCommentId(),
|
|
295
295
|
body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
|
|
296
|
+
systemKey: 'sys.execFailed',
|
|
297
|
+
systemParams: { error: message.slice(0, 300) },
|
|
296
298
|
version: 1,
|
|
297
299
|
createdAt: this.deps.now(),
|
|
298
300
|
})
|
|
@@ -598,6 +600,7 @@ export class ExecutionService {
|
|
|
598
600
|
body: normalizeBody(commented
|
|
599
601
|
? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
|
|
600
602
|
: '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
|
|
603
|
+
systemKey: commented ? 'sys.endedWithComment' : 'sys.endedNoHandoff',
|
|
601
604
|
version: 1,
|
|
602
605
|
createdAt: now,
|
|
603
606
|
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side locale reading for the few user-facing HOST log lines (the
|
|
3
|
+
* gitignore suggestion). Everything else the user sees is localized by the
|
|
4
|
+
* GUI (client i18n dictionaries), and host-written ledger comments carry a
|
|
5
|
+
* `systemKey` that the GUI localizes at render — so this module is only for
|
|
6
|
+
* the raw `console.*` lines the host emits itself.
|
|
7
|
+
*
|
|
8
|
+
* Source: the DSH locale plugin persists the explicit language choice as
|
|
9
|
+
* `locale.preference` in `$DSH_HOME/settings.yaml` (loopback pages) and
|
|
10
|
+
* exposes it through the settings service. When the preference is absent the
|
|
11
|
+
* browser delegates, which the host cannot see — so we fall back to `en`,
|
|
12
|
+
* matching the client's own fallback (zh only when something asked for it).
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-taskboard/host/locale
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
|
|
18
|
+
/** The two locales the taskboard ships. */
|
|
19
|
+
export type HostLocale = 'zh' | 'en'
|
|
20
|
+
|
|
21
|
+
/** Narrow settings-service face this module consumes (stringly-typed soft access). */
|
|
22
|
+
interface SettingsFace {
|
|
23
|
+
get?: (ns: string) => unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The DSH locale settings section (`preference` carries the explicit choice). */
|
|
27
|
+
interface LocaleSettings {
|
|
28
|
+
preference?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the active GUI locale as a host-side hint. Absent / malformed settings
|
|
33
|
+
* (or no settings service in scope) fall back to `en` and never throw — a
|
|
34
|
+
* cosmetic log line must not break route boot.
|
|
35
|
+
*/
|
|
36
|
+
export function activeHostLocale(ctx: Context): HostLocale {
|
|
37
|
+
try {
|
|
38
|
+
const settings = ctx.get('settings') as SettingsFace | undefined
|
|
39
|
+
const locale = settings?.get?.('locale') as LocaleSettings | undefined
|
|
40
|
+
return locale?.preference === 'zh' ? 'zh' : 'en'
|
|
41
|
+
} catch {
|
|
42
|
+
return 'en'
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/host/routes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { archiveTaskSessions } from './archive-sessions.ts'
|
|
1
2
|
/**
|
|
2
3
|
* /dsh-taskboard routes on the shared DSH webserver: a JSON API for the
|
|
3
4
|
* GUI's human operations (create/update/move/comment/delete — actor `user`,
|
|
@@ -40,12 +41,16 @@ import {
|
|
|
40
41
|
type TaskLedger,
|
|
41
42
|
type TaskModel,
|
|
42
43
|
type TaskRecord,
|
|
44
|
+
type SystemCommentRow,
|
|
43
45
|
} from '../shared/protocol.ts'
|
|
44
46
|
import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
|
|
45
47
|
import { removeMirror, repoMainPath } from './isolation.ts'
|
|
46
48
|
import { createRepoScanner, type RepoScanner } from './repos.ts'
|
|
49
|
+
import { activeHostLocale } from './locale.ts'
|
|
47
50
|
import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
|
|
48
51
|
import type { TemplateStore } from './templates.ts'
|
|
52
|
+
import { MAX_ASSET_BYTES, type AssetStore } from './assets.ts'
|
|
53
|
+
import type { StorageCoordinator } from './storage.ts'
|
|
49
54
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
50
55
|
import type { TaskStore } from './store.ts'
|
|
51
56
|
import { ERR, ToolError } from './tools.ts'
|
|
@@ -61,6 +66,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
|
61
66
|
const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
|
|
62
67
|
const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
|
|
63
68
|
const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`)
|
|
69
|
+
const ASSET_RE = new RegExp(`^${ROUTE_PREFIX}/assets/([a-f0-9]{64}\\.(?:png|jpg|gif|webp))$`)
|
|
64
70
|
|
|
65
71
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
66
72
|
const GIT_DETECT_TTL_MS = 60_000
|
|
@@ -88,6 +94,12 @@ export interface TaskboardRoutesOptions {
|
|
|
88
94
|
scanner?: RepoScanner
|
|
89
95
|
/** Task-template store (0.4.0); absent → 501 on template actions. */
|
|
90
96
|
templates?: TemplateStore
|
|
97
|
+
/** Durable image attachment store (0.7.0); absent → attachment routes unavailable. */
|
|
98
|
+
assets?: AssetStore
|
|
99
|
+
/** Configurable data-directory coordinator (0.7.0). */
|
|
100
|
+
storage?: StorageCoordinator
|
|
101
|
+
/** Initial storage/ledger readiness barrier. */
|
|
102
|
+
ready?: () => Promise<void>
|
|
91
103
|
/** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
|
|
92
104
|
promptCompletions?: () => Promise<{
|
|
93
105
|
skills?: Array<{ name: string; description?: string }>
|
|
@@ -189,6 +201,19 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown> |
|
|
|
189
201
|
}
|
|
190
202
|
}
|
|
191
203
|
|
|
204
|
+
/** Read one bounded binary upload without ever buffering beyond the file cap. */
|
|
205
|
+
async function readBytes(req: IncomingMessage, limit: number): Promise<Buffer> {
|
|
206
|
+
const chunks: Buffer[] = []
|
|
207
|
+
let total = 0
|
|
208
|
+
for await (const chunk of req) {
|
|
209
|
+
const bytes = chunk as Buffer
|
|
210
|
+
total += bytes.length
|
|
211
|
+
if (total > limit) throw new Error('body too large')
|
|
212
|
+
chunks.push(bytes)
|
|
213
|
+
}
|
|
214
|
+
return Buffer.concat(chunks)
|
|
215
|
+
}
|
|
216
|
+
|
|
192
217
|
/** String field accessor (null when absent/not a string). */
|
|
193
218
|
function str(body: Record<string, unknown>, key: string): string | null {
|
|
194
219
|
const v = body[key]
|
|
@@ -316,10 +341,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
316
341
|
} catch { /* fail-soft → false */ }
|
|
317
342
|
// gitignore 建议 (plan §3.2): suggest (never write) ignoring our
|
|
318
343
|
// worktree directory, once per workspace per host run. Root repos only.
|
|
344
|
+
// The line is localized from the DSH locale preference (see host/locale.ts).
|
|
319
345
|
if (rootRepo && !gitHinted.has(path)) {
|
|
320
346
|
gitHinted.add(path)
|
|
321
347
|
if (await gitignoreMissing(path)) {
|
|
322
|
-
|
|
348
|
+
const file = `${path}/.gitignore`
|
|
349
|
+
const hint = activeHostLocale(ctx) === 'zh'
|
|
350
|
+
? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`
|
|
351
|
+
: `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`
|
|
352
|
+
console.info(`[dsh-taskboard] ${hint}`)
|
|
323
353
|
}
|
|
324
354
|
}
|
|
325
355
|
// The nested scan always runs: repoCount needs it even when the root
|
|
@@ -370,9 +400,28 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
370
400
|
|
|
371
401
|
// ---------------------------------------------------------------- GET
|
|
372
402
|
if (req.method === 'GET') {
|
|
403
|
+
if (pathname === `${ROUTE_PREFIX}/storage`) {
|
|
404
|
+
if (options.storage === undefined) { res.writeHead(501); res.end(); return }
|
|
405
|
+
json(res, { ok: true, value: await options.storage.status() })
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
await options.ready?.()
|
|
409
|
+
const assetMatch = pathname.match(ASSET_RE)
|
|
410
|
+
if (assetMatch !== null) {
|
|
411
|
+
const asset = await options.assets?.read(assetMatch[1]!)
|
|
412
|
+
if (asset === undefined) { res.writeHead(404); res.end(); return }
|
|
413
|
+
res.writeHead(200, {
|
|
414
|
+
'content-type': asset.mime,
|
|
415
|
+
'content-length': asset.bytes.length,
|
|
416
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
417
|
+
'x-content-type-options': 'nosniff',
|
|
418
|
+
})
|
|
419
|
+
res.end(asset.bytes)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
373
422
|
if (pathname === `${ROUTE_PREFIX}/state`) {
|
|
374
423
|
await store.load()
|
|
375
|
-
json(res, { ok: true, value: store.snapshot() })
|
|
424
|
+
json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
|
|
376
425
|
return
|
|
377
426
|
}
|
|
378
427
|
if (pathname === `${ROUTE_PREFIX}/workspaces`) {
|
|
@@ -519,6 +568,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
519
568
|
res.end()
|
|
520
569
|
return
|
|
521
570
|
}
|
|
571
|
+
// Content-addressed image upload. The custom header makes this a
|
|
572
|
+
// non-simple cross-origin request, preserving the JSON routes' CSRF fence.
|
|
573
|
+
if (pathname === `${ROUTE_PREFIX}/assets`) {
|
|
574
|
+
await options.ready?.()
|
|
575
|
+
if (options.assets === undefined) {
|
|
576
|
+
const f = fail('invalid_input', 'image attachments unavailable')
|
|
577
|
+
json(res, f.res, 501)
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if (req.headers['x-dsh-taskboard-upload'] !== '1') {
|
|
581
|
+
const f = fail('forbidden', 'missing upload header')
|
|
582
|
+
json(res, f.res, 403)
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
const declaredMime = String(req.headers['content-type'] ?? '').split(';', 1)[0]!.trim().toLowerCase()
|
|
586
|
+
try {
|
|
587
|
+
const bytes = await readBytes(req, MAX_ASSET_BYTES)
|
|
588
|
+
await options.assets.cleanup(JSON.stringify(store.snapshot()))
|
|
589
|
+
const asset = await options.assets.put(bytes, declaredMime)
|
|
590
|
+
json(res, { ok: true, value: asset }, 201)
|
|
591
|
+
} catch (error) {
|
|
592
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
593
|
+
const status = message.includes('1..') ? 413 : message.includes('quota') ? 507 : 400
|
|
594
|
+
const f = fail('invalid_input', message)
|
|
595
|
+
json(res, f.res, status)
|
|
596
|
+
}
|
|
597
|
+
return
|
|
598
|
+
}
|
|
522
599
|
// CSRF fence: cross-site simple requests cannot set application/json.
|
|
523
600
|
const contentType = req.headers['content-type'] ?? ''
|
|
524
601
|
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
@@ -540,6 +617,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
540
617
|
return
|
|
541
618
|
}
|
|
542
619
|
|
|
620
|
+
// Storage location is bootstrap metadata outside the ledger. Keep it on
|
|
621
|
+
// separate routes so JSON imports and whole-ledger replacement cannot
|
|
622
|
+
// redirect host filesystem writes.
|
|
623
|
+
if (pathname === `${ROUTE_PREFIX}/storage/check` || pathname === `${ROUTE_PREFIX}/storage/migrate`) {
|
|
624
|
+
if (options.storage === undefined) {
|
|
625
|
+
const f = fail('invalid_input', 'storage configuration unavailable')
|
|
626
|
+
json(res, f.res, 501)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const directory = str(body, 'directory') ?? ''
|
|
631
|
+
const value = pathname.endsWith('/check')
|
|
632
|
+
? await options.storage.check(directory)
|
|
633
|
+
: await options.storage.migrate(directory)
|
|
634
|
+
json(res, { ok: true, value })
|
|
635
|
+
} catch (error) {
|
|
636
|
+
const f = fail('invalid_input', error instanceof Error ? error.message : String(error))
|
|
637
|
+
json(res, f.res, f.status)
|
|
638
|
+
}
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
await options.ready?.()
|
|
642
|
+
|
|
543
643
|
// ------------------------------------------------- POST /tasks (create)
|
|
544
644
|
if (pathname === `${ROUTE_PREFIX}/tasks`) {
|
|
545
645
|
try {
|
|
@@ -615,6 +715,12 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
615
715
|
try {
|
|
616
716
|
const task = store.get(id)
|
|
617
717
|
if (task === undefined) throw new Error('Error: not_found: no such task')
|
|
718
|
+
if (action === 'archive-sessions') {
|
|
719
|
+
if (task.trashedAt !== undefined || task.status !== 'archived') throw new Error('Error: invalid_transition: only archived live tasks can retry session archiving')
|
|
720
|
+
const result = await archiveTaskSessions(task, workspaces.archiveSession)
|
|
721
|
+
json(res, { ok: true, value: result })
|
|
722
|
+
return
|
|
723
|
+
}
|
|
618
724
|
if (action === 'update') {
|
|
619
725
|
const ifVersion = num(body, 'ifVersion')
|
|
620
726
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
@@ -678,13 +784,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
678
784
|
if (action === 'move') {
|
|
679
785
|
const ifVersion = num(body, 'ifVersion')
|
|
680
786
|
const status = str(body, 'status') ?? ''
|
|
787
|
+
const archiveSessions = body.archiveSessions === true
|
|
681
788
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
682
789
|
const to = asStatus(status)
|
|
683
790
|
let next: TaskRecord | undefined
|
|
791
|
+
let beforeTask: TaskRecord | undefined
|
|
684
792
|
await store.mutate('task-moved', ledger => {
|
|
685
793
|
const { index, task } = liveTaskAt(ledger, id)
|
|
686
794
|
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
687
795
|
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
|
|
796
|
+
beforeTask = task
|
|
688
797
|
next = structuredClone(task)
|
|
689
798
|
next.status = to
|
|
690
799
|
next.version = task.version + 1
|
|
@@ -696,7 +805,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
696
805
|
ledger.tasks[index] = next
|
|
697
806
|
return [next]
|
|
698
807
|
})
|
|
699
|
-
|
|
808
|
+
const sessionArchive = to === 'archived' && archiveSessions
|
|
809
|
+
? await archiveTaskSessions(beforeTask ?? next!, workspaces.archiveSession)
|
|
810
|
+
: undefined
|
|
811
|
+
json(res, { ok: true, value: { ...summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) } })
|
|
700
812
|
return
|
|
701
813
|
}
|
|
702
814
|
if (action === 'reject') {
|
|
@@ -909,11 +1021,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
909
1021
|
}
|
|
910
1022
|
// R1: the git merges above are slow — re-find the FRESH task inside
|
|
911
1023
|
// the mutation so a concurrent comment is never overwritten.
|
|
912
|
-
const pushComment = (body: string): Promise<void> =>
|
|
1024
|
+
const pushComment = (body: string, system?: { key: string; params?: Record<string, string>; rows?: SystemCommentRow[] }): Promise<void> =>
|
|
913
1025
|
store.mutate('comment-added', ledger => {
|
|
914
1026
|
const { index, task: fresh } = liveTaskAt(ledger, id)
|
|
915
1027
|
const next = structuredClone(fresh)
|
|
916
|
-
next.comments.push({
|
|
1028
|
+
next.comments.push({
|
|
1029
|
+
id: newCommentId(),
|
|
1030
|
+
body: normalizeBody(body),
|
|
1031
|
+
...(system !== undefined ? {
|
|
1032
|
+
systemKey: system.key,
|
|
1033
|
+
...(system.params !== undefined ? { systemParams: system.params } : {}),
|
|
1034
|
+
...(system.rows !== undefined ? { systemRows: system.rows } : {}),
|
|
1035
|
+
} : {}),
|
|
1036
|
+
version: 1,
|
|
1037
|
+
createdAt: options.now(),
|
|
1038
|
+
})
|
|
917
1039
|
next.version = fresh.version + 1
|
|
918
1040
|
next.updatedAt = options.now()
|
|
919
1041
|
ledger.tasks[index] = next
|
|
@@ -930,7 +1052,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
930
1052
|
if (root.outcome === 'failed') {
|
|
931
1053
|
throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
|
|
932
1054
|
}
|
|
933
|
-
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff
|
|
1055
|
+
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, { key: 'sys.mergeSingle', params: { branch: root.branch } })
|
|
934
1056
|
json(res, { ok: true, value: { merged: true, branch: root.branch } })
|
|
935
1057
|
return
|
|
936
1058
|
}
|
|
@@ -942,7 +1064,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
942
1064
|
? `${labelOf(r.repo)} ✓ 已合并`
|
|
943
1065
|
: r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
|
|
944
1066
|
.join(';')
|
|
945
|
-
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}
|
|
1067
|
+
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`, {
|
|
1068
|
+
key: 'sys.mergeMulti',
|
|
1069
|
+
rows: results.map(r => ({ repo: r.repo, outcome: r.outcome, ...(r.error !== undefined ? { error: r.error.slice(0, 150) } : {}) })),
|
|
1070
|
+
})
|
|
946
1071
|
json(res, {
|
|
947
1072
|
ok: true,
|
|
948
1073
|
value: {
|
package/src/host/scheduler.ts
CHANGED
|
@@ -136,6 +136,8 @@ export class SchedulerService {
|
|
|
136
136
|
task.comments.push({
|
|
137
137
|
id: newCommentId(),
|
|
138
138
|
body: normalizeBody(`[系统] 定时表达式 ${deadCron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。`),
|
|
139
|
+
systemKey: 'sys.cronDead',
|
|
140
|
+
systemParams: { cron: deadCron },
|
|
139
141
|
version: 1,
|
|
140
142
|
createdAt: now,
|
|
141
143
|
})
|
package/src/host/session-sync.ts
CHANGED
|
@@ -213,7 +213,7 @@ export class ExternalSessionSyncService {
|
|
|
213
213
|
|
|
214
214
|
constructor(private readonly deps: SessionSyncDeps) {
|
|
215
215
|
this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
|
|
216
|
-
|
|
216
|
+
return this.handleSessionEvent(sessionId, event, sessionMeta)
|
|
217
217
|
})
|
|
218
218
|
|
|
219
219
|
const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS
|
|
@@ -628,6 +628,8 @@ export class ExternalSessionSyncService {
|
|
|
628
628
|
task.comments.push({
|
|
629
629
|
id: newCommentId(),
|
|
630
630
|
body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
|
|
631
|
+
systemKey: 'sys.sessionError',
|
|
632
|
+
systemParams: { error: errorMessage.slice(0, 300) },
|
|
631
633
|
version: 1,
|
|
632
634
|
createdAt: now,
|
|
633
635
|
})
|
|
@@ -639,6 +641,7 @@ export class ExternalSessionSyncService {
|
|
|
639
641
|
task.comments.push({
|
|
640
642
|
id: newCommentId(),
|
|
641
643
|
body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
|
|
644
|
+
systemKey: 'sys.sessionDone',
|
|
642
645
|
version: 1,
|
|
643
646
|
createdAt: now,
|
|
644
647
|
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One process-wide serial queue shared by all taskboard persistence. */
|
|
2
|
+
export class StorageQueue {
|
|
3
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
4
|
+
|
|
5
|
+
run<T>(operation: () => Promise<T>): Promise<T> {
|
|
6
|
+
const result = this.tail.then(operation, operation)
|
|
7
|
+
this.tail = result.then(() => undefined, () => undefined)
|
|
8
|
+
return result
|
|
9
|
+
}
|
|
10
|
+
}
|