dsh-taskboard 0.6.6 → 0.6.7
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 +301 -294
- package/lib/client.js +937 -931
- package/lib/host/archive-sessions.js +30 -0
- package/lib/host/archive-sessions.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 +45 -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/templates.js +11 -75
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +17 -4
- package/lib/host/tools.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 -90
- package/src/client/api.ts +5 -1
- package/src/client/board/TaskBoard.tsx +16 -11
- package/src/client/board/TaskDetail.tsx +116 -10
- package/src/client/board/TemplateManager.tsx +22 -10
- package/src/client/controller.ts +23 -3
- package/src/client/i18n/en.ts +18 -0
- package/src/client/i18n/templates.ts +25 -0
- package/src/client/i18n/zh.ts +18 -0
- package/src/client/styles.ts +1 -1
- package/src/host/archive-sessions.ts +18 -0
- package/src/host/execution.ts +4 -1
- package/src/host/locale.ts +44 -0
- package/src/host/routes.ts +40 -7
- package/src/host/scheduler.ts +2 -0
- package/src/host/session-sync.ts +4 -1
- package/src/host/templates.ts +8 -59
- package/src/host/tools.ts +20 -7
- package/src/shared/api.ts +6 -3
- package/src/shared/builtin-templates.ts +153 -0
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +9 -9
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { useEffect, useState, type ReactNode } from 'react'
|
|
11
11
|
import type { BoardController } from '../controller.ts'
|
|
12
|
-
import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
|
|
13
|
-
import { canTransition, checklistProgress } from '../../shared/protocol.ts'
|
|
12
|
+
import type { CommentRecord, ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
|
|
13
|
+
import { canTransition, checklistProgress, taskAssociatedSessionIds } from '../../shared/protocol.ts'
|
|
14
14
|
import { useAlert } from './AlertModal.tsx'
|
|
15
15
|
import { fmtTime, isStaleClaim } from './format.ts'
|
|
16
16
|
import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
|
|
17
|
-
import { useT } from '../i18n/runtime.ts'
|
|
17
|
+
import { useT, type Translate } from '../i18n/runtime.ts'
|
|
18
18
|
|
|
19
19
|
/** Statuses a user may move this task to, per the state machine. */
|
|
20
20
|
function moveTargets(task: TaskRecord): TaskRecord['status'][] {
|
|
@@ -28,6 +28,26 @@ function shortId(id: string | undefined): string {
|
|
|
28
28
|
return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Render a comment body, localizing host-generated system messages (0.6.4).
|
|
33
|
+
* System comments carry a `systemKey` (+ flat params, or structured per-repo
|
|
34
|
+
* rows for the multi-repo merge summary); user/agent comments render raw.
|
|
35
|
+
*/
|
|
36
|
+
export function commentBody(t: Translate, c: CommentRecord): string {
|
|
37
|
+
if (c.systemKey === undefined) return c.body
|
|
38
|
+
if (c.systemRows !== undefined) {
|
|
39
|
+
const summary = c.systemRows
|
|
40
|
+
.map(r => {
|
|
41
|
+
const label = r.repo === '' ? t('iso.repo.root') : r.repo
|
|
42
|
+
const mark = r.outcome === 'merged' ? '✓' : r.outcome === 'noop' ? '⟲' : '✗'
|
|
43
|
+
return r.outcome === 'failed' && r.error !== undefined ? `${label} ${mark} ${r.error}` : `${label} ${mark}`
|
|
44
|
+
})
|
|
45
|
+
.join(' · ')
|
|
46
|
+
return t(c.systemKey, { summary })
|
|
47
|
+
}
|
|
48
|
+
return t(c.systemKey, c.systemParams)
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/** Execution duration between start and end. */
|
|
32
52
|
function duration(startedAt: number | undefined, endedAt: number | undefined): string {
|
|
33
53
|
if (startedAt === undefined || endedAt === undefined) return ''
|
|
@@ -497,6 +517,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
497
517
|
const [confirmDone, setConfirmDone] = useState(false)
|
|
498
518
|
const [confirmPurge, setConfirmPurge] = useState(false)
|
|
499
519
|
const [confirmCancel, setConfirmCancel] = useState(false)
|
|
520
|
+
const [confirmArchive, setConfirmArchive] = useState(false)
|
|
500
521
|
// Top action buttons (duplicate / save-as-template / run / reuse-run)
|
|
501
522
|
// share one in-flight guard: a double click used to fire duplicate runs or
|
|
502
523
|
// copies while the first round-trip was still pending (review P0).
|
|
@@ -510,6 +531,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
510
531
|
const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
|
|
511
532
|
const sessionExecution = [...task.executions].reverse().find(e => e.sessionId !== undefined)
|
|
512
533
|
const targetSessionId = runningExecution?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
|
|
534
|
+
const associatedSessions = taskAssociatedSessionIds(task)
|
|
535
|
+
const archiveState = controller.getSnapshot()
|
|
536
|
+
const archiveResult = archiveState.sessionArchive?.taskId === task.id ? archiveState.sessionArchive.result : undefined
|
|
513
537
|
|
|
514
538
|
/** Fire one top action under the shared busy guard; re-enable on settle. */
|
|
515
539
|
const runAction = (action: () => Promise<unknown>): void => {
|
|
@@ -680,10 +704,21 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
680
704
|
|
|
681
705
|
<ChecklistBlock task={task} controller={controller} />
|
|
682
706
|
|
|
707
|
+
{archiveResult !== undefined && (
|
|
708
|
+
<div role="status" className="dsh-atb-confirm">
|
|
709
|
+
<span>{t('detail.move.archiveResult', { n: archiveResult.archived.length })}</span>
|
|
710
|
+
{archiveResult.failed.map(item => <span key={item.sessionId}>{item.sessionId}: {item.error}</span>)}
|
|
711
|
+
{archiveResult.unsupported.length > 0 && <span>{t('detail.move.archiveUnsupported')} {archiveResult.unsupported.join(', ')}</span>}
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
{task.status === 'archived' && associatedSessions.length > 0 && archiveState.archiveSessionsSupported && (
|
|
715
|
+
<button type="button" className="dsh-atb-btn" disabled={actionBusy} onClick={() => runAction(() => controller.retryArchiveSessions(task.id))}>{t('detail.move.archiveRetry')}</button>
|
|
716
|
+
)}
|
|
683
717
|
<div className="dsh-atb-detail-actions">
|
|
684
718
|
<div className="dsh-atb-movebtns">
|
|
685
|
-
{moveTargets(task).map(to =>
|
|
686
|
-
|
|
719
|
+
{moveTargets(task).map(to => {
|
|
720
|
+
if (to === 'done') {
|
|
721
|
+
return confirmDone
|
|
687
722
|
? (
|
|
688
723
|
<span key={to} className="dsh-atb-confirm">
|
|
689
724
|
<span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
|
|
@@ -693,12 +728,83 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
693
728
|
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>{t('shared.cancel')}</button>
|
|
694
729
|
</span>
|
|
695
730
|
)
|
|
696
|
-
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>
|
|
697
|
-
|
|
698
|
-
|
|
731
|
+
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => { setConfirmDone(true); setConfirmArchive(false) }}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>
|
|
732
|
+
}
|
|
733
|
+
if (to === 'archived') {
|
|
734
|
+
if (confirmArchive) {
|
|
735
|
+
return (
|
|
736
|
+
<span key={to} className="dsh-atb-confirm">
|
|
737
|
+
<span className="dsh-atb-confirm-label">
|
|
738
|
+
{associatedSessions.length === 1
|
|
739
|
+
? t('detail.move.confirmArchiveSessionWithId', { id: shortId(associatedSessions[0]) })
|
|
740
|
+
: associatedSessions.length > 1
|
|
741
|
+
? t('detail.move.confirmArchiveSessionCount', { n: associatedSessions.length })
|
|
742
|
+
: t('detail.move.confirmArchive')}
|
|
743
|
+
</span>
|
|
744
|
+
{associatedSessions.length > 0 && <span className="dsh-atb-confirm-label">{associatedSessions.join(', ')}</span>}
|
|
745
|
+
{associatedSessions.length > 0 ? (
|
|
746
|
+
<>
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
className="dsh-atb-btn"
|
|
750
|
+
disabled={!archiveState.archiveSessionsSupported || actionBusy}
|
|
751
|
+
title={!archiveState.archiveSessionsSupported ? t('detail.move.archiveUnsupported') : undefined}
|
|
752
|
+
onClick={() => {
|
|
753
|
+
runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: true }))
|
|
754
|
+
setConfirmArchive(false)
|
|
755
|
+
}}
|
|
756
|
+
>
|
|
757
|
+
{t('detail.move.archiveWithSession')}
|
|
758
|
+
</button>
|
|
759
|
+
<button
|
|
760
|
+
type="button"
|
|
761
|
+
className="dsh-atb-btn"
|
|
762
|
+
onClick={() => {
|
|
763
|
+
runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: false }))
|
|
764
|
+
setConfirmArchive(false)
|
|
765
|
+
}}
|
|
766
|
+
>
|
|
767
|
+
{t('detail.move.archiveCardOnly')}
|
|
768
|
+
</button>
|
|
769
|
+
</>
|
|
770
|
+
) : (
|
|
771
|
+
<button
|
|
772
|
+
type="button"
|
|
773
|
+
className="dsh-atb-btn"
|
|
774
|
+
data-primary="true"
|
|
775
|
+
onClick={() => {
|
|
776
|
+
void controller.move(task.id, task.version, 'archived')
|
|
777
|
+
setConfirmArchive(false)
|
|
778
|
+
}}
|
|
779
|
+
>
|
|
780
|
+
{t('detail.move.confirm')}
|
|
781
|
+
</button>
|
|
782
|
+
)}
|
|
783
|
+
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmArchive(false)}>{t('shared.cancel')}</button>
|
|
784
|
+
</span>
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
return (
|
|
788
|
+
<button
|
|
789
|
+
key={to}
|
|
790
|
+
type="button"
|
|
791
|
+
className="dsh-atb-movebtn"
|
|
792
|
+
data-to={to}
|
|
793
|
+
onClick={() => {
|
|
794
|
+
setConfirmArchive(true)
|
|
795
|
+
setConfirmDone(false)
|
|
796
|
+
}}
|
|
797
|
+
>
|
|
699
798
|
{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
|
|
700
799
|
</button>
|
|
701
|
-
)
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
return (
|
|
803
|
+
<button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
|
|
804
|
+
{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
|
|
805
|
+
</button>
|
|
806
|
+
)
|
|
807
|
+
})}
|
|
702
808
|
<button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
|
|
703
809
|
{task.blocked ? t('detail.blocked.unmark') : t('detail.blocked.mark')}
|
|
704
810
|
</button>
|
|
@@ -730,7 +836,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
730
836
|
<b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
|
|
731
837
|
<span>{fmtTime(c.createdAt)}</span>
|
|
732
838
|
</div>
|
|
733
|
-
<div className="dsh-atb-bubble-body">{c
|
|
839
|
+
<div className="dsh-atb-bubble-body">{commentBody(t, c)}</div>
|
|
734
840
|
</div>
|
|
735
841
|
</div>
|
|
736
842
|
))}
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useState } from 'react'
|
|
9
9
|
import type { BoardController } from '../controller.ts'
|
|
10
|
+
import type { TaskTemplate } from '../../shared/api.ts'
|
|
11
|
+
import type { Urgency } from '../../shared/protocol.ts'
|
|
10
12
|
import { useAlert } from './AlertModal.tsx'
|
|
13
|
+
import { URGENCY_KEYS } from './labels.ts'
|
|
11
14
|
import { useT } from '../i18n/runtime.ts'
|
|
15
|
+
import { localizeBuiltinName, localizeBuiltinTask } from '../i18n/templates.ts'
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* The template manager modal.
|
|
@@ -23,12 +27,20 @@ export function TemplateManager({ controller }: { controller: BoardController })
|
|
|
23
27
|
|
|
24
28
|
const close = (): void => controller.closeTemplateManager()
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
/** The name a row currently shows: an in-flight rename edit, else the localized name. */
|
|
31
|
+
const nameOf = (tpl: TaskTemplate): string => edits[tpl.id] ?? localizeBuiltinName(tpl)
|
|
32
|
+
|
|
33
|
+
/** Localized urgency label for the meta line ('' when the template pins none). */
|
|
34
|
+
const urgencyLabel = (urgency: string | undefined): string => {
|
|
35
|
+
if (urgency === undefined) return ''
|
|
36
|
+
const key = URGENCY_KEYS[urgency as Urgency]
|
|
37
|
+
return key !== undefined ? ` · ${t(key)}` : ` · ${urgency}`
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
/** Save one template's rename. */
|
|
29
41
|
const save = (id: string, name: string): void => {
|
|
30
42
|
const template = state.templates.find(t => t.id === id)
|
|
31
|
-
if (template === undefined || name === template
|
|
43
|
+
if (template === undefined || name === localizeBuiltinName(template)) return
|
|
32
44
|
void controller.upsertTemplate({ id, name, task: template.task }).then(ok => {
|
|
33
45
|
if (ok) {
|
|
34
46
|
setEdits(prev => { const next = { ...prev }; delete next[id]; return next })
|
|
@@ -57,31 +69,31 @@ export function TemplateManager({ controller }: { controller: BoardController })
|
|
|
57
69
|
<div key={tpl.id} className="dsh-atb-tplm-row">
|
|
58
70
|
<input
|
|
59
71
|
className="dsh-atb-tplm-name"
|
|
60
|
-
value={nameOf(tpl
|
|
72
|
+
value={nameOf(tpl)}
|
|
61
73
|
maxLength={60}
|
|
62
74
|
spellCheck={false}
|
|
63
|
-
aria-label={t('tpl.name.aria', { name: tpl
|
|
75
|
+
aria-label={t('tpl.name.aria', { name: localizeBuiltinName(tpl) })}
|
|
64
76
|
onChange={e => setEdits(prev => ({ ...prev, [tpl.id]: e.target.value }))}
|
|
65
77
|
onKeyDown={e => {
|
|
66
|
-
if (e.key === 'Enter') save(tpl.id, nameOf(tpl
|
|
78
|
+
if (e.key === 'Enter') save(tpl.id, nameOf(tpl))
|
|
67
79
|
}}
|
|
68
80
|
/>
|
|
69
81
|
<span
|
|
70
82
|
className="dsh-atb-tplm-meta"
|
|
71
|
-
title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${tpl.task.urgency
|
|
83
|
+
title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${urgencyLabel(tpl.task.urgency)}${tpl.task.permission !== undefined ? ` · ${t('shared.permission')}: ${tpl.task.permission}` : ''}`}
|
|
72
84
|
>
|
|
73
85
|
{tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}
|
|
74
86
|
{tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}
|
|
75
|
-
{tpl.task.urgency
|
|
87
|
+
{urgencyLabel(tpl.task.urgency)}
|
|
76
88
|
{tpl.task.permission !== undefined && tpl.task.permission !== 'workspace-write' ? ` · ${tpl.task.permission === 'read-only' ? t('tpl.meta.permReadOnly') : t('tpl.meta.permFull')}` : ''}
|
|
77
89
|
</span>
|
|
78
90
|
<span className="dsh-atb-tplm-btns">
|
|
79
91
|
<button
|
|
80
92
|
type="button"
|
|
81
93
|
className="dsh-atb-btn"
|
|
82
|
-
disabled={nameOf(tpl
|
|
94
|
+
disabled={nameOf(tpl) === localizeBuiltinName(tpl) || nameOf(tpl).trim().length === 0}
|
|
83
95
|
title={t('tpl.rename.title')}
|
|
84
|
-
onClick={() => save(tpl.id, nameOf(tpl
|
|
96
|
+
onClick={() => save(tpl.id, nameOf(tpl))}
|
|
85
97
|
>
|
|
86
98
|
{t('tpl.rename.button')}
|
|
87
99
|
</button>
|
|
@@ -91,7 +103,7 @@ export function TemplateManager({ controller }: { controller: BoardController })
|
|
|
91
103
|
title={t('tpl.use.title')}
|
|
92
104
|
onClick={() => {
|
|
93
105
|
close()
|
|
94
|
-
controller.newFromTemplate(tpl
|
|
106
|
+
controller.newFromTemplate(localizeBuiltinTask(tpl))
|
|
95
107
|
}}
|
|
96
108
|
>
|
|
97
109
|
{t('tpl.use.button')}
|
package/src/client/controller.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionArchiveResult } from '../shared/api.ts'
|
|
1
2
|
/**
|
|
2
3
|
* The board controller: framework-free state holder the React views render
|
|
3
4
|
* from. Owns the ledger snapshot, workspace listing, view state (open,
|
|
@@ -47,6 +48,8 @@ function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortB
|
|
|
47
48
|
|
|
48
49
|
/** Controller snapshot the views render. */
|
|
49
50
|
export interface ControllerState {
|
|
51
|
+
archiveSessionsSupported?: boolean
|
|
52
|
+
sessionArchive?: { taskId: string; result: SessionArchiveResult }
|
|
50
53
|
boardOpen: boolean
|
|
51
54
|
ledger: TaskLedger
|
|
52
55
|
workspaces: WorkspaceView[]
|
|
@@ -185,7 +188,7 @@ export class BoardController {
|
|
|
185
188
|
if (this.state.selectedId !== undefined) {
|
|
186
189
|
selected = ledger.tasks.find(t => t.id === this.state.selectedId)
|
|
187
190
|
}
|
|
188
|
-
this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
|
|
191
|
+
this.setState({ archiveSessionsSupported: ledger.capabilities?.archiveSessions === true, ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
|
|
189
192
|
if (this.seenRevision === undefined || ledger.revision >= this.seenRevision) break
|
|
190
193
|
}
|
|
191
194
|
} catch (error) {
|
|
@@ -422,10 +425,27 @@ export class BoardController {
|
|
|
422
425
|
}
|
|
423
426
|
}
|
|
424
427
|
|
|
428
|
+
/** Retry session archiving without repeating the task's terminal transition. */
|
|
429
|
+
async retryArchiveSessions(id: string): Promise<void> {
|
|
430
|
+
try {
|
|
431
|
+
if (this.client.archiveSessions === undefined) return
|
|
432
|
+
const result = await this.client.archiveSessions(id)
|
|
433
|
+
this.setState({ sessionArchive: { taskId: id, result } })
|
|
434
|
+
await this.refresh()
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
425
440
|
/** Move a task (user surface: done allowed). */
|
|
426
|
-
async move(id: string, ifVersion: number, status: string): Promise<void> {
|
|
441
|
+
async move(id: string, ifVersion: number, status: string, options?: { archiveSessions?: boolean }): Promise<void> {
|
|
427
442
|
try {
|
|
428
|
-
await this.client.move(id, {
|
|
443
|
+
const result = await this.client.move(id, {
|
|
444
|
+
ifVersion,
|
|
445
|
+
status,
|
|
446
|
+
...(options?.archiveSessions !== undefined ? { archiveSessions: options.archiveSessions } : {}),
|
|
447
|
+
})
|
|
448
|
+
if (result.sessionArchive !== undefined) this.setState({ sessionArchive: { taskId: id, result: result.sessionArchive } })
|
|
429
449
|
await this.refresh()
|
|
430
450
|
} catch (error) {
|
|
431
451
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
package/src/client/i18n/en.ts
CHANGED
|
@@ -213,6 +213,14 @@ export const en: TaskboardDict = {
|
|
|
213
213
|
'detail.move.to': 'Move to → {status}',
|
|
214
214
|
'detail.move.confirmDoneUnchecked': '{n} checklist items are still unchecked — confirm done?',
|
|
215
215
|
'detail.move.confirmDone': 'Confirm done?',
|
|
216
|
+
'detail.move.confirmArchive': 'Confirm archive this task?',
|
|
217
|
+
'detail.move.confirmArchiveSessionWithId': 'Archive execution session {id} together?',
|
|
218
|
+
'detail.move.confirmArchiveSessionCount': 'Archive {n} execution sessions together?',
|
|
219
|
+
'detail.move.archiveWithSession': 'With session',
|
|
220
|
+
'detail.move.archiveUnsupported': 'Session archiving is unavailable on this host',
|
|
221
|
+
'detail.move.archiveResult': 'Card archived; {n} execution sessions archived. Failed sessions, if any:',
|
|
222
|
+
'detail.move.archiveRetry': 'Archive / retry execution sessions',
|
|
223
|
+
'detail.move.archiveCardOnly': 'Card only',
|
|
216
224
|
'detail.move.confirm': 'Confirm',
|
|
217
225
|
'detail.blocked.unmark': '✓ Unblock',
|
|
218
226
|
'detail.blocked.mark': '⛔ Mark blocked',
|
|
@@ -456,4 +464,14 @@ export const en: TaskboardDict = {
|
|
|
456
464
|
'slash.skill.source-driven-development': 'Design from authoritative docs and source code',
|
|
457
465
|
'slash.skill.spec-driven-development': 'Write clear technical specs before coding',
|
|
458
466
|
'slash.skill.using-agent-skills': 'Discover and invoke agent skills dynamically',
|
|
467
|
+
|
|
468
|
+
// ── host-generated system comments (0.6.4; localized at render) ────
|
|
469
|
+
'sys.execFailed': '[System] Execution failed: {error}; the task was returned to todo.',
|
|
470
|
+
'sys.endedWithComment': '[System] The execution session ended with comments but was not moved to in review; the system moved it to in review automatically.',
|
|
471
|
+
'sys.endedNoHandoff': '[System] The execution session ended without a protocol handoff (no comments, not moved to in review); the system moved it to in review automatically — review it, then send back or accept.',
|
|
472
|
+
'sys.sessionError': '[System] Session execution error: {error}; the task was returned to todo.',
|
|
473
|
+
'sys.sessionDone': '[System] The session finished; automatically moved to in review.',
|
|
474
|
+
'sys.cronDead': '[System] The cron expression {cron} has no trigger time within 4 years; scheduling disabled — fix the cron and re-enable.',
|
|
475
|
+
'sys.mergeSingle': '[System] Branch {branch} merged into the main worktree (--no-ff).',
|
|
476
|
+
'sys.mergeMulti': '[System] Branches merged per repo (--no-ff): {summary}',
|
|
459
477
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side localization of built-in task templates (0.6.4). Built-in
|
|
3
|
+
* templates are seeded host-side with zh fallback text; this module resolves
|
|
4
|
+
* the ACTIVE locale's content (name + task spec) so the new-task dropdown,
|
|
5
|
+
* the template manager, and the create-form prefill follow the GUI language
|
|
6
|
+
* live. Custom templates (no `builtin` flag) and unknown built-in ids pass
|
|
7
|
+
* through untouched.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-taskboard/client/i18n/templates
|
|
10
|
+
*/
|
|
11
|
+
import type { TaskTemplate, TaskTemplateSpec } from '../../shared/api.ts'
|
|
12
|
+
import { builtinTemplateContent } from '../../shared/builtin-templates.ts'
|
|
13
|
+
import { localeStore } from './runtime.ts'
|
|
14
|
+
|
|
15
|
+
/** The localized display name for a template (stored name for custom ones). */
|
|
16
|
+
export function localizeBuiltinName(tpl: Pick<TaskTemplate, 'id' | 'name' | 'builtin'>): string {
|
|
17
|
+
if (tpl.builtin !== true) return tpl.name
|
|
18
|
+
return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.name ?? tpl.name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The localized task spec for a template (stored spec for custom ones). */
|
|
22
|
+
export function localizeBuiltinTask(tpl: Pick<TaskTemplate, 'id' | 'task' | 'builtin'>): TaskTemplateSpec {
|
|
23
|
+
if (tpl.builtin !== true) return tpl.task
|
|
24
|
+
return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.task ?? tpl.task
|
|
25
|
+
}
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -215,6 +215,14 @@ export const zh = {
|
|
|
215
215
|
'detail.move.to': '移至→{status}',
|
|
216
216
|
'detail.move.confirmDoneUnchecked': '仍有 {n} 项清单未勾选,确认完成?',
|
|
217
217
|
'detail.move.confirmDone': '确认完成?',
|
|
218
|
+
'detail.move.confirmArchive': '确认归档该任务?',
|
|
219
|
+
'detail.move.confirmArchiveSessionWithId': '是否连同执行会话 {id} 一同归档?',
|
|
220
|
+
'detail.move.confirmArchiveSessionCount': '是否连同 {n} 个执行会话一同归档?',
|
|
221
|
+
'detail.move.archiveWithSession': '连同会话归档',
|
|
222
|
+
'detail.move.archiveUnsupported': '当前宿主不支持会话归档',
|
|
223
|
+
'detail.move.archiveResult': '卡片已归档;执行会话归档成功 {n} 个。失败会话如下(如有):',
|
|
224
|
+
'detail.move.archiveRetry': '归档 / 重试归档执行会话',
|
|
225
|
+
'detail.move.archiveCardOnly': '仅归档卡片',
|
|
218
226
|
'detail.move.confirm': '确认',
|
|
219
227
|
'detail.blocked.unmark': '✓ 解除受阻',
|
|
220
228
|
'detail.blocked.mark': '⛔ 标记受阻',
|
|
@@ -458,6 +466,16 @@ export const zh = {
|
|
|
458
466
|
'slash.skill.source-driven-development': '基于权威官方文档与源码进行设计实现',
|
|
459
467
|
'slash.skill.spec-driven-development': '在编码前制定清晰的技术规范',
|
|
460
468
|
'slash.skill.using-agent-skills': '发现并动态调用智能体各项专业技能',
|
|
469
|
+
|
|
470
|
+
// ── host-generated system comments (0.6.4; localized at render) ────
|
|
471
|
+
'sys.execFailed': '[系统] 执行失败:{error};任务已退回待办。',
|
|
472
|
+
'sys.endedWithComment': '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。',
|
|
473
|
+
'sys.endedNoHandoff': '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。',
|
|
474
|
+
'sys.sessionError': '[系统] 会话执行异常:{error};任务已退回待办。',
|
|
475
|
+
'sys.sessionDone': '[系统] 会话执行完毕,已自动进入待验收。',
|
|
476
|
+
'sys.cronDead': '[系统] 定时表达式 {cron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。',
|
|
477
|
+
'sys.mergeSingle': '[系统] 分支 {branch} 已合并到主工作区(--no-ff)。',
|
|
478
|
+
'sys.mergeMulti': '[系统] 分支已按仓库合并(--no-ff):{summary}',
|
|
461
479
|
} as const
|
|
462
480
|
|
|
463
481
|
/** The dictionary shape en.ts must match key-for-key. */
|
package/src/client/styles.ts
CHANGED
|
@@ -365,7 +365,7 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
|
|
|
365
365
|
.dsh-atb-movebtn[data-to="canceled"], .dsh-atb-movebtn[data-to="archived"] { opacity: .75; }
|
|
366
366
|
.dsh-atb-movebtn[data-to="blocked"] { border-color: rgba(229,72,77,.45); }
|
|
367
367
|
.dsh-atb-movebtn[data-to="blocked"]:hover { background: rgba(229,72,77,.1); }
|
|
368
|
-
.dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; }
|
|
368
|
+
.dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
369
369
|
.dsh-atb-confirm-label { font-size: 11.5px; color: var(--dsw-text-secondary, gray); }
|
|
370
370
|
|
|
371
371
|
.dsh-atb-section { font-size: 13px; display: flex; flex-direction: column; gap: 7px; }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SessionArchiveResult } from '../shared/api.ts'
|
|
2
|
+
import { taskAssociatedSessionIds, type TaskRecord } from '../shared/protocol.ts'
|
|
3
|
+
|
|
4
|
+
/** Idempotent best-effort archiving with explicit per-session outcomes. */
|
|
5
|
+
export async function archiveTaskSessions(task: TaskRecord, archive?: (id: string) => Promise<void>): Promise<SessionArchiveResult> {
|
|
6
|
+
const sessionIds = taskAssociatedSessionIds(task)
|
|
7
|
+
if (archive === undefined) return { archived: [], failed: [], unsupported: sessionIds }
|
|
8
|
+
const result: SessionArchiveResult = { archived: [], failed: [], unsupported: [] }
|
|
9
|
+
for (const sessionId of sessionIds) {
|
|
10
|
+
try {
|
|
11
|
+
await archive(sessionId)
|
|
12
|
+
result.archived.push(sessionId)
|
|
13
|
+
} catch (error) {
|
|
14
|
+
result.failed.push({ sessionId, error: error instanceof Error ? error.message : String(error) })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
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,10 +41,12 @@ 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'
|
|
49
52
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
@@ -316,10 +319,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
316
319
|
} catch { /* fail-soft → false */ }
|
|
317
320
|
// gitignore 建议 (plan §3.2): suggest (never write) ignoring our
|
|
318
321
|
// worktree directory, once per workspace per host run. Root repos only.
|
|
322
|
+
// The line is localized from the DSH locale preference (see host/locale.ts).
|
|
319
323
|
if (rootRepo && !gitHinted.has(path)) {
|
|
320
324
|
gitHinted.add(path)
|
|
321
325
|
if (await gitignoreMissing(path)) {
|
|
322
|
-
|
|
326
|
+
const file = `${path}/.gitignore`
|
|
327
|
+
const hint = activeHostLocale(ctx) === 'zh'
|
|
328
|
+
? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`
|
|
329
|
+
: `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`
|
|
330
|
+
console.info(`[dsh-taskboard] ${hint}`)
|
|
323
331
|
}
|
|
324
332
|
}
|
|
325
333
|
// The nested scan always runs: repoCount needs it even when the root
|
|
@@ -372,7 +380,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
372
380
|
if (req.method === 'GET') {
|
|
373
381
|
if (pathname === `${ROUTE_PREFIX}/state`) {
|
|
374
382
|
await store.load()
|
|
375
|
-
json(res, { ok: true, value: store.snapshot() })
|
|
383
|
+
json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
|
|
376
384
|
return
|
|
377
385
|
}
|
|
378
386
|
if (pathname === `${ROUTE_PREFIX}/workspaces`) {
|
|
@@ -615,6 +623,12 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
615
623
|
try {
|
|
616
624
|
const task = store.get(id)
|
|
617
625
|
if (task === undefined) throw new Error('Error: not_found: no such task')
|
|
626
|
+
if (action === 'archive-sessions') {
|
|
627
|
+
if (task.trashedAt !== undefined || task.status !== 'archived') throw new Error('Error: invalid_transition: only archived live tasks can retry session archiving')
|
|
628
|
+
const result = await archiveTaskSessions(task, workspaces.archiveSession)
|
|
629
|
+
json(res, { ok: true, value: result })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
618
632
|
if (action === 'update') {
|
|
619
633
|
const ifVersion = num(body, 'ifVersion')
|
|
620
634
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
@@ -678,13 +692,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
678
692
|
if (action === 'move') {
|
|
679
693
|
const ifVersion = num(body, 'ifVersion')
|
|
680
694
|
const status = str(body, 'status') ?? ''
|
|
695
|
+
const archiveSessions = body.archiveSessions === true
|
|
681
696
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
682
697
|
const to = asStatus(status)
|
|
683
698
|
let next: TaskRecord | undefined
|
|
699
|
+
let beforeTask: TaskRecord | undefined
|
|
684
700
|
await store.mutate('task-moved', ledger => {
|
|
685
701
|
const { index, task } = liveTaskAt(ledger, id)
|
|
686
702
|
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
687
703
|
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
|
|
704
|
+
beforeTask = task
|
|
688
705
|
next = structuredClone(task)
|
|
689
706
|
next.status = to
|
|
690
707
|
next.version = task.version + 1
|
|
@@ -696,7 +713,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
696
713
|
ledger.tasks[index] = next
|
|
697
714
|
return [next]
|
|
698
715
|
})
|
|
699
|
-
|
|
716
|
+
const sessionArchive = to === 'archived' && archiveSessions
|
|
717
|
+
? await archiveTaskSessions(beforeTask ?? next!, workspaces.archiveSession)
|
|
718
|
+
: undefined
|
|
719
|
+
json(res, { ok: true, value: { ...summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) } })
|
|
700
720
|
return
|
|
701
721
|
}
|
|
702
722
|
if (action === 'reject') {
|
|
@@ -909,11 +929,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
909
929
|
}
|
|
910
930
|
// R1: the git merges above are slow — re-find the FRESH task inside
|
|
911
931
|
// the mutation so a concurrent comment is never overwritten.
|
|
912
|
-
const pushComment = (body: string): Promise<void> =>
|
|
932
|
+
const pushComment = (body: string, system?: { key: string; params?: Record<string, string>; rows?: SystemCommentRow[] }): Promise<void> =>
|
|
913
933
|
store.mutate('comment-added', ledger => {
|
|
914
934
|
const { index, task: fresh } = liveTaskAt(ledger, id)
|
|
915
935
|
const next = structuredClone(fresh)
|
|
916
|
-
next.comments.push({
|
|
936
|
+
next.comments.push({
|
|
937
|
+
id: newCommentId(),
|
|
938
|
+
body: normalizeBody(body),
|
|
939
|
+
...(system !== undefined ? {
|
|
940
|
+
systemKey: system.key,
|
|
941
|
+
...(system.params !== undefined ? { systemParams: system.params } : {}),
|
|
942
|
+
...(system.rows !== undefined ? { systemRows: system.rows } : {}),
|
|
943
|
+
} : {}),
|
|
944
|
+
version: 1,
|
|
945
|
+
createdAt: options.now(),
|
|
946
|
+
})
|
|
917
947
|
next.version = fresh.version + 1
|
|
918
948
|
next.updatedAt = options.now()
|
|
919
949
|
ledger.tasks[index] = next
|
|
@@ -930,7 +960,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
930
960
|
if (root.outcome === 'failed') {
|
|
931
961
|
throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
|
|
932
962
|
}
|
|
933
|
-
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff
|
|
963
|
+
await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, { key: 'sys.mergeSingle', params: { branch: root.branch } })
|
|
934
964
|
json(res, { ok: true, value: { merged: true, branch: root.branch } })
|
|
935
965
|
return
|
|
936
966
|
}
|
|
@@ -942,7 +972,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
942
972
|
? `${labelOf(r.repo)} ✓ 已合并`
|
|
943
973
|
: r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
|
|
944
974
|
.join(';')
|
|
945
|
-
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}
|
|
975
|
+
await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`, {
|
|
976
|
+
key: 'sys.mergeMulti',
|
|
977
|
+
rows: results.map(r => ({ repo: r.repo, outcome: r.outcome, ...(r.error !== undefined ? { error: r.error.slice(0, 150) } : {}) })),
|
|
978
|
+
})
|
|
946
979
|
json(res, {
|
|
947
980
|
ok: true,
|
|
948
981
|
value: {
|