dsh-taskboard 0.6.7 → 0.7.1
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 +21 -65
- package/lib/client.js +58 -8
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/routes.js +87 -0
- package/lib/host/routes.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 +62 -38
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +6 -4
- 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/package.json +13 -11
- package/src/client/api.ts +30 -0
- package/src/client/board/SettingsModal.tsx +75 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskDetail.tsx +71 -8
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/controller.ts +54 -3
- package/src/client/i18n/en.ts +19 -3
- package/src/client/i18n/zh.ts +19 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +37 -1
- package/src/host/assets.ts +120 -0
- package/src/host/routes.ts +92 -0
- 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 +30 -7
- package/src/host/tools.ts +8 -4
- package/src/index.ts +88 -33
- package/src/shared/api.ts +26 -0
- package/src/shared/version.ts +2 -1
package/src/client/controller.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { SessionArchiveResult } from '../shared/api.ts'
|
|
|
8
8
|
*
|
|
9
9
|
* @module dsh-taskboard/client/controller
|
|
10
10
|
*/
|
|
11
|
-
import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
|
|
11
|
+
import type { AttachmentUpload, ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, StorageStatus, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
|
|
12
12
|
import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
|
|
13
13
|
import { emptyLedger } from '../shared/protocol.ts'
|
|
14
14
|
import type { TaskboardClient } from './api.ts'
|
|
@@ -78,6 +78,10 @@ export interface ControllerState {
|
|
|
78
78
|
importOpen: boolean
|
|
79
79
|
/** Board-settings modal visible (0.5.0). */
|
|
80
80
|
settingsOpen: boolean
|
|
81
|
+
/** Current durable-data directory, loaded when settings opens. */
|
|
82
|
+
storage?: StorageStatus
|
|
83
|
+
/** Settled migration outcome (0.7.0): success path plus non-fatal warnings. */
|
|
84
|
+
storageNotice?: { path: string; warnings: string[] }
|
|
81
85
|
/** Fields a chosen template prefills into the create form (consumed on open). */
|
|
82
86
|
templatePrefill?: TaskTemplateSpec
|
|
83
87
|
/** Transient error surface (action failures); cleared on next success. */
|
|
@@ -524,6 +528,16 @@ export class BoardController {
|
|
|
524
528
|
}
|
|
525
529
|
}
|
|
526
530
|
|
|
531
|
+
/** Upload an image without putting its bytes in the ledger or agent context. */
|
|
532
|
+
async uploadImage(file: Blob): Promise<AttachmentUpload | undefined> {
|
|
533
|
+
try {
|
|
534
|
+
return await this.client.uploadImage(file)
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
537
|
+
return undefined
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
527
541
|
/** Trigger a manual run (fresh in-project session, pinned model); `reuse` = 续跑. */
|
|
528
542
|
async run(id: string, reuse = false): Promise<void> {
|
|
529
543
|
try {
|
|
@@ -587,10 +601,15 @@ export class BoardController {
|
|
|
587
601
|
closeDiagnostics(): void { this.setState({ diagOpen: false }) }
|
|
588
602
|
|
|
589
603
|
/** Open the board-settings modal (0.5.0). */
|
|
590
|
-
openSettings(): void {
|
|
604
|
+
openSettings(): void {
|
|
605
|
+
this.setState({ settingsOpen: true })
|
|
606
|
+
void this.client.storage()
|
|
607
|
+
.then(storage => this.setState({ storage, error: undefined }))
|
|
608
|
+
.catch(error => this.setState({ error: error instanceof Error ? error.message : String(error) }))
|
|
609
|
+
}
|
|
591
610
|
|
|
592
611
|
/** Close the board-settings modal. */
|
|
593
|
-
closeSettings(): void { this.setState({ settingsOpen: false }) }
|
|
612
|
+
closeSettings(): void { this.setState({ settingsOpen: false, storageNotice: undefined }) }
|
|
594
613
|
|
|
595
614
|
/**
|
|
596
615
|
* Replace board settings (0.5.0). The host broadcasts a settings-updated
|
|
@@ -608,6 +627,38 @@ export class BoardController {
|
|
|
608
627
|
}
|
|
609
628
|
}
|
|
610
629
|
|
|
630
|
+
/** Validate a candidate host directory without changing the active store. */
|
|
631
|
+
async checkStorage(directory: string): Promise<boolean> {
|
|
632
|
+
try {
|
|
633
|
+
const storage = await this.client.checkStorage(directory)
|
|
634
|
+
this.setState({ storage, error: undefined })
|
|
635
|
+
return true
|
|
636
|
+
} catch (error) {
|
|
637
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
638
|
+
return false
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Atomically migrate all three stores and refresh the displayed location. */
|
|
643
|
+
async migrateStorage(directory: string): Promise<boolean> {
|
|
644
|
+
try {
|
|
645
|
+
const storage = await this.client.migrateStorage(directory)
|
|
646
|
+
// Success is surfaced as a dedicated notice (not the error banner);
|
|
647
|
+
// cleanup warnings render beneath it as warnings, never as errors.
|
|
648
|
+
this.setState({ storage, storageNotice: { path: storage.currentDirectory, warnings: storage.warnings }, error: undefined })
|
|
649
|
+
await this.refresh()
|
|
650
|
+
return true
|
|
651
|
+
} catch (error) {
|
|
652
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Clear the settled migration notice (next user edit in the storage section). */
|
|
658
|
+
dismissStorageNotice(): void {
|
|
659
|
+
if (this.state.storageNotice !== undefined) this.setState({ storageNotice: undefined })
|
|
660
|
+
}
|
|
661
|
+
|
|
611
662
|
/** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
|
|
612
663
|
async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
|
|
613
664
|
try {
|
package/src/client/i18n/en.ts
CHANGED
|
@@ -339,14 +339,14 @@ export const en: TaskboardDict = {
|
|
|
339
339
|
'tpl.use.button': 'Use',
|
|
340
340
|
'tpl.delete.title': 'Delete this template',
|
|
341
341
|
'tpl.renamed': 'Template renamed',
|
|
342
|
-
'tpl.foot.hint': 'Templates are stored with the ledger in the
|
|
342
|
+
'tpl.foot.hint': 'Templates are stored with the ledger in the active data directory and survive upgrades',
|
|
343
343
|
|
|
344
344
|
// ── import modal (ImportModal) ────────────────────────────────────
|
|
345
345
|
'imp.aria': 'Import ledger',
|
|
346
346
|
'imp.title': 'Import ledger',
|
|
347
347
|
'imp.subtitle': 'Pick an exported JSON backup: preview first, then merge or replace everything',
|
|
348
348
|
'imp.parseError': 'The file is not valid JSON',
|
|
349
|
-
'imp.note': 'The ⬇ JSON export
|
|
349
|
+
'imp.note': 'The ⬇ JSON export restores the ledger. Back up the dsh-taskboard-assets folder in the data directory shown in Settings as well. The file\u2019s schemaVersion must match the current version.',
|
|
350
350
|
'imp.previewing': 'Previewing…',
|
|
351
351
|
'imp.stat.create': 'New',
|
|
352
352
|
'imp.stat.overwrite': 'Overwrite (same id)',
|
|
@@ -370,7 +370,7 @@ export const en: TaskboardDict = {
|
|
|
370
370
|
// ── board settings modal (SettingsModal) ──────────────────────────
|
|
371
371
|
'set.aria': 'Board settings',
|
|
372
372
|
'set.title': 'Board settings',
|
|
373
|
-
'set.subtitle': '
|
|
373
|
+
'set.subtitle': 'New-task defaults, session sync, and local data storage',
|
|
374
374
|
'set.iso.heading': 'Default execution isolation',
|
|
375
375
|
'set.iso.noneHint': 'No git; works directly in the project directory (factory default)',
|
|
376
376
|
'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others; multi-repo workspaces are mirrored whole (one branch per repo)',
|
|
@@ -387,6 +387,18 @@ export const en: TaskboardDict = {
|
|
|
387
387
|
'set.foot.dirty': 'Unsaved changes',
|
|
388
388
|
'set.foot.clean': 'Matches the current board settings',
|
|
389
389
|
'set.action.save': 'Save settings',
|
|
390
|
+
'set.storage.heading': 'Data storage location',
|
|
391
|
+
'set.storage.hint': 'The task ledger, templates, and image attachments live together in this directory. Changing it copies and verifies everything before switching.',
|
|
392
|
+
'set.storage.loading': 'Loading the current path…',
|
|
393
|
+
'set.storage.current': 'Current path: {path}',
|
|
394
|
+
'set.storage.assets': 'Image attachments: {count}, {size} MiB',
|
|
395
|
+
'set.storage.default': 'Restore default path',
|
|
396
|
+
'set.storage.check': 'Check path',
|
|
397
|
+
'set.storage.migrate': 'Migrate data',
|
|
398
|
+
'set.storage.migrating': 'Migrating…',
|
|
399
|
+
'set.storage.confirm': 'Migrate the complete task ledger, templates, and image attachments?\n\nFrom: {from}\nTo: {to}\n\nWrites queue briefly during migration.',
|
|
400
|
+
'set.storage.migrated': 'Migration succeeded — data now lives in {path}',
|
|
401
|
+
'set.storage.warnings': 'Migration finished, but some old files could not be removed: {warnings}',
|
|
390
402
|
|
|
391
403
|
// ── execution permission (PR #14) ─────────────────────────────────
|
|
392
404
|
'form.field.permission': 'Execution permission',
|
|
@@ -420,6 +432,10 @@ export const en: TaskboardDict = {
|
|
|
420
432
|
'md.imageTitle': 'Click to view full size ({alt})',
|
|
421
433
|
'md.lightboxAlt': 'Full-size preview',
|
|
422
434
|
'md.closePreview': 'Close preview',
|
|
435
|
+
'image.add': 'Insert image',
|
|
436
|
+
'image.uploading': 'Uploading…',
|
|
437
|
+
'image.hint': 'Choose, paste, or drop PNG/JPEG/GIF/WebP images up to 5 MB each',
|
|
438
|
+
'image.defaultAlt': 'Image',
|
|
423
439
|
|
|
424
440
|
// ── slash completion popup (PR #14) ───────────────────────────────
|
|
425
441
|
'slash.aria': 'Quick commands and skills',
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -341,14 +341,14 @@ export const zh = {
|
|
|
341
341
|
'tpl.use.button': '用此新建',
|
|
342
342
|
'tpl.delete.title': '删除该模板',
|
|
343
343
|
'tpl.renamed': '模板已改名',
|
|
344
|
-
'tpl.foot.hint': '
|
|
344
|
+
'tpl.foot.hint': '模板随台账一同保存在当前数据目录,升级不丢',
|
|
345
345
|
|
|
346
346
|
// ── import modal (ImportModal) ────────────────────────────────────
|
|
347
347
|
'imp.aria': '导入台账',
|
|
348
348
|
'imp.title': '导入台账',
|
|
349
349
|
'imp.subtitle': '选择导出的 JSON 备份文件:先预览、再合并或整册替换',
|
|
350
350
|
'imp.parseError': '文件不是合法 JSON',
|
|
351
|
-
'imp.note': '⬇ JSON
|
|
351
|
+
'imp.note': '⬇ JSON 导出可恢复台账;图片附件需同时备份设置页所示数据目录下的 dsh-taskboard-assets 文件夹。导入文件的 schemaVersion 必须与当前版本一致。',
|
|
352
352
|
'imp.previewing': '预览中…',
|
|
353
353
|
'imp.stat.create': '新增',
|
|
354
354
|
'imp.stat.overwrite': '覆盖(同 id)',
|
|
@@ -372,7 +372,7 @@ export const zh = {
|
|
|
372
372
|
// ── board settings modal (SettingsModal) ──────────────────────────
|
|
373
373
|
'set.aria': '看板设置',
|
|
374
374
|
'set.title': '看板设置',
|
|
375
|
-
'set.subtitle': '
|
|
375
|
+
'set.subtitle': '新建任务、会话同步与本地数据存储',
|
|
376
376
|
'set.iso.heading': '默认执行隔离',
|
|
377
377
|
'set.iso.noneHint': '不使用 git,直接在项目目录工作(出厂默认)',
|
|
378
378
|
'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染;多仓库工作区自动整区镜像(每仓库独立分支)',
|
|
@@ -389,6 +389,18 @@ export const zh = {
|
|
|
389
389
|
'set.foot.dirty': '有未保存的修改',
|
|
390
390
|
'set.foot.clean': '与看板当前设置一致',
|
|
391
391
|
'set.action.save': '保存设置',
|
|
392
|
+
'set.storage.heading': '数据存储位置',
|
|
393
|
+
'set.storage.hint': '任务台账、模板和图片附件统一存放在此目录。修改路径会先完整复制并校验,再切换到新位置。',
|
|
394
|
+
'set.storage.loading': '正在读取当前路径…',
|
|
395
|
+
'set.storage.current': '当前路径:{path}',
|
|
396
|
+
'set.storage.assets': '图片附件:{count} 个,{size} MiB',
|
|
397
|
+
'set.storage.default': '恢复默认路径',
|
|
398
|
+
'set.storage.check': '检查路径',
|
|
399
|
+
'set.storage.migrate': '迁移数据',
|
|
400
|
+
'set.storage.migrating': '迁移中…',
|
|
401
|
+
'set.storage.confirm': '确认迁移全部任务台账、模板和图片附件?\n\n原路径:{from}\n新路径:{to}\n\n迁移期间写操作会短暂排队。',
|
|
402
|
+
'set.storage.migrated': '迁移成功,数据已迁移到 {path}',
|
|
403
|
+
'set.storage.warnings': '迁移完成,但部分旧数据未能清理:{warnings}',
|
|
392
404
|
|
|
393
405
|
// ── execution permission (PR #14) ─────────────────────────────────
|
|
394
406
|
'form.field.permission': '执行权限',
|
|
@@ -422,6 +434,10 @@ export const zh = {
|
|
|
422
434
|
'md.imageTitle': '点击查看大图 ({alt})',
|
|
423
435
|
'md.lightboxAlt': '大图预览',
|
|
424
436
|
'md.closePreview': '关闭预览',
|
|
437
|
+
'image.add': '插入图片',
|
|
438
|
+
'image.uploading': '正在上传…',
|
|
439
|
+
'image.hint': '支持选择、粘贴或拖入 PNG/JPEG/GIF/WebP,单张不超过 5 MB',
|
|
440
|
+
'image.defaultAlt': '图片',
|
|
425
441
|
|
|
426
442
|
// ── slash completion popup (PR #14) ───────────────────────────────
|
|
427
443
|
'slash.aria': '快捷命令与技能',
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AttachmentUpload } from '../shared/api.ts'
|
|
2
|
+
|
|
3
|
+
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp'
|
|
4
|
+
const TYPES = new Set(IMAGE_ACCEPT.split(','))
|
|
5
|
+
|
|
6
|
+
/** Keep Markdown alt text single-line and unable to close its own bracket. */
|
|
7
|
+
export function imageAlt(fileName: string, fallback: string): string {
|
|
8
|
+
const withoutExtension = fileName.replace(/\.(?:png|jpe?g|gif|webp)$/i, '')
|
|
9
|
+
const clean = withoutExtension.replace(/[\[\]\r\n]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
10
|
+
return clean.length > 0 ? clean.slice(0, 120) : fallback
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function imageMarkdown(asset: AttachmentUpload, alt: string): string {
|
|
14
|
+
return ``
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Insert a block at the current selection, preserving readable line boundaries. */
|
|
18
|
+
export function insertImageMarkdown(value: string, start: number, end: number, markdown: string): { value: string; cursor: number } {
|
|
19
|
+
const before = value.slice(0, start)
|
|
20
|
+
const after = value.slice(end)
|
|
21
|
+
const prefix = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
|
|
22
|
+
const suffix = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
|
|
23
|
+
const inserted = `${prefix}${markdown}${suffix}`
|
|
24
|
+
return { value: before + inserted + after, cursor: before.length + inserted.length }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function acceptedImageFiles(files: Iterable<File>): File[] {
|
|
28
|
+
return Array.from(files).filter(file => TYPES.has(file.type)).slice(0, 10)
|
|
29
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -398,6 +398,27 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
|
|
|
398
398
|
.dsh-atb-bubble-meta span { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
|
|
399
399
|
.dsh-atb-bubble-body { font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
|
|
400
400
|
|
|
401
|
+
.dsh-atb-markdown-body { white-space: pre-wrap; word-break: break-word; }
|
|
402
|
+
.dsh-atb-detail-img-wrap {
|
|
403
|
+
display: inline-flex; flex-direction: column; gap: 4px; max-width: min(100%, 520px); margin: 6px 8px 6px 0;
|
|
404
|
+
vertical-align: top;
|
|
405
|
+
}
|
|
406
|
+
.dsh-atb-detail-img {
|
|
407
|
+
display: block; max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 8px; cursor: zoom-in;
|
|
408
|
+
border: 1px solid var(--dsw-border, rgba(128,128,128,.22)); background: var(--dsw-bg-inset, rgba(128,128,128,.08));
|
|
409
|
+
}
|
|
410
|
+
.dsh-atb-detail-img-caption { font-size: 10.5px; color: var(--dsw-text-secondary, gray); overflow-wrap: anywhere; }
|
|
411
|
+
.dsh-atb-lightbox-backdrop {
|
|
412
|
+
position: fixed; inset: 0; z-index: 120; display: grid; place-items: center; padding: 28px;
|
|
413
|
+
background: rgba(0,0,0,.76); backdrop-filter: blur(3px);
|
|
414
|
+
}
|
|
415
|
+
.dsh-atb-lightbox-content { position: relative; max-width: 96vw; max-height: 92vh; }
|
|
416
|
+
.dsh-atb-lightbox-img { display: block; max-width: 96vw; max-height: 92vh; object-fit: contain; border-radius: 10px; }
|
|
417
|
+
.dsh-atb-lightbox-close {
|
|
418
|
+
position: absolute; top: -14px; right: -14px; width: 30px; height: 30px; border-radius: 999px; cursor: pointer;
|
|
419
|
+
border: 1px solid rgba(255,255,255,.38); background: rgba(20,20,20,.9); color: #fff;
|
|
420
|
+
}
|
|
421
|
+
|
|
401
422
|
.dsh-atb-composer { display: flex; gap: 7px; align-items: flex-end; margin-top: 2px; }
|
|
402
423
|
.dsh-atb-composer-input {
|
|
403
424
|
flex: 1; font: inherit; font-size: 12.5px; line-height: 1.5; padding: 7px 10px; border-radius: 9px;
|
|
@@ -410,6 +431,14 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
|
|
|
410
431
|
border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
411
432
|
}
|
|
412
433
|
.dsh-atb-composer-send:disabled { opacity: .4; cursor: default; }
|
|
434
|
+
.dsh-atb-image-actions { display: flex; align-items: center; gap: 8px; padding: 5px 1px 0; }
|
|
435
|
+
.dsh-atb-image-add {
|
|
436
|
+
flex: none; font: inherit; font-size: 11.5px; line-height: 1.4; padding: 4px 8px; border-radius: 7px; cursor: pointer;
|
|
437
|
+
border: 1px solid var(--dsw-border, rgba(128,128,128,.3)); background: var(--dsw-bg-elevated, rgba(128,128,128,.08)); color: inherit;
|
|
438
|
+
}
|
|
439
|
+
.dsh-atb-image-add:hover:not(:disabled) { background: var(--dsw-bg-hover, rgba(128,128,128,.14)); }
|
|
440
|
+
.dsh-atb-image-add:disabled { opacity: .45; cursor: default; }
|
|
441
|
+
.dsh-atb-image-hint { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
|
|
413
442
|
|
|
414
443
|
.dsh-atb-execlist { display: flex; flex-direction: column; gap: 5px; }
|
|
415
444
|
.dsh-atb-exec-row {
|
|
@@ -854,9 +883,16 @@ color: var(--dsw-alias-state-business-primary, #3e63dd);
|
|
|
854
883
|
.dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
|
|
855
884
|
.dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
|
|
856
885
|
/* ---------- 0.5.0 board settings ---------- */
|
|
857
|
-
.dsh-atb-set { max-width:
|
|
886
|
+
.dsh-atb-set { max-width: 620px; width: min(620px, 92vw); }
|
|
858
887
|
.dsh-atb-set .dsh-atb-mode-picker { margin-top: 8px; }
|
|
859
888
|
.dsh-atb-set .dsh-atb-isolation-note { margin-top: 10px; }
|
|
889
|
+
.dsh-atb-storage-path { width: 100%; margin-top: 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
890
|
+
.dsh-atb-storage-meta { display: grid; gap: 4px; margin-top: 8px; color: var(--dsh-atb-muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
891
|
+
.dsh-atb-storage-error { color: var(--dsh-atb-danger); }
|
|
892
|
+
.dsh-atb-storage-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
|
893
|
+
.dsh-atb-storage-notice { display: grid; gap: 4px; margin-top: 8px; font-size: 12px; }
|
|
894
|
+
.dsh-atb-storage-notice-ok { color: var(--dsw-alias-state-success-primary, #2e7d32); padding: 4px 8px; border-radius: 6px; background: rgba(46,125,50,.1); }
|
|
895
|
+
.dsh-atb-storage-notice-warn { color: var(--dsw-alias-state-warning-primary, #b8860b); padding: 4px 8px; border-radius: 6px; background: rgba(184,134,11,.1); word-break: break-word; }
|
|
860
896
|
|
|
861
897
|
/* ---------- 0.5.5 SlashPromptInput & Permission Picker ---------- */
|
|
862
898
|
.dsh-atb-perm-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
|
|
@@ -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/routes.ts
CHANGED
|
@@ -49,6 +49,8 @@ import { createRepoScanner, type RepoScanner } from './repos.ts'
|
|
|
49
49
|
import { activeHostLocale } from './locale.ts'
|
|
50
50
|
import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
|
|
51
51
|
import type { TemplateStore } from './templates.ts'
|
|
52
|
+
import { MAX_ASSET_BYTES, type AssetStore } from './assets.ts'
|
|
53
|
+
import type { StorageCoordinator } from './storage.ts'
|
|
52
54
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
53
55
|
import type { TaskStore } from './store.ts'
|
|
54
56
|
import { ERR, ToolError } from './tools.ts'
|
|
@@ -64,6 +66,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
|
64
66
|
const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
|
|
65
67
|
const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
|
|
66
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))$`)
|
|
67
70
|
|
|
68
71
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
69
72
|
const GIT_DETECT_TTL_MS = 60_000
|
|
@@ -91,6 +94,12 @@ export interface TaskboardRoutesOptions {
|
|
|
91
94
|
scanner?: RepoScanner
|
|
92
95
|
/** Task-template store (0.4.0); absent → 501 on template actions. */
|
|
93
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>
|
|
94
103
|
/** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
|
|
95
104
|
promptCompletions?: () => Promise<{
|
|
96
105
|
skills?: Array<{ name: string; description?: string }>
|
|
@@ -192,6 +201,19 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown> |
|
|
|
192
201
|
}
|
|
193
202
|
}
|
|
194
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
|
+
|
|
195
217
|
/** String field accessor (null when absent/not a string). */
|
|
196
218
|
function str(body: Record<string, unknown>, key: string): string | null {
|
|
197
219
|
const v = body[key]
|
|
@@ -378,6 +400,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
378
400
|
|
|
379
401
|
// ---------------------------------------------------------------- GET
|
|
380
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
|
+
}
|
|
381
422
|
if (pathname === `${ROUTE_PREFIX}/state`) {
|
|
382
423
|
await store.load()
|
|
383
424
|
json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
|
|
@@ -527,6 +568,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
527
568
|
res.end()
|
|
528
569
|
return
|
|
529
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
|
+
}
|
|
530
599
|
// CSRF fence: cross-site simple requests cannot set application/json.
|
|
531
600
|
const contentType = req.headers['content-type'] ?? ''
|
|
532
601
|
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
@@ -548,6 +617,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
548
617
|
return
|
|
549
618
|
}
|
|
550
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
|
+
|
|
551
643
|
// ------------------------------------------------- POST /tasks (create)
|
|
552
644
|
if (pathname === `${ROUTE_PREFIX}/tasks`) {
|
|
553
645
|
try {
|
|
@@ -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
|
+
}
|