@linxin666/dsh-client-ui-task-board 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +6 -2
- package/README.zh.md +6 -2
- package/lib/client.js +437 -187
- package/lib/client.js.map +1 -1
- package/lib/index.js +45 -9
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/board/EditTaskModal.d.ts +8 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/telemetry.d.ts +26 -0
- package/lib/types/core/controller.d.ts +9 -1
- package/lib/types/core/use-cases/task-update.d.ts +19 -3
- package/lib/types/http.d.ts +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/mount-once.d.ts +1 -1
- package/package.json +2 -4
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/board/EditTaskModal.tsx +94 -0
- package/src/client/board/TaskBoard.tsx +1 -1
- package/src/client/board/TaskDetail.tsx +19 -0
- package/src/client/index.ts +6 -1
- package/src/client/locales.ts +6 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/core/controller.ts +11 -3
- package/src/core/use-cases/task-update.ts +33 -3
- package/src/dsh-home.ts +9 -3
- package/src/host-ledger.ts +9 -1
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/loopback.ts +1 -1
- package/src/mount-once.ts +2 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/telemetry.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Anonymous install telemetry shared by the DSH Web UI family plugins.
|
|
4
|
+
*
|
|
5
|
+
* Once per UTC day per browser, each wired plugin sends one heartbeat to
|
|
6
|
+
* the dsh-market edge API listing its package name (and version when known).
|
|
7
|
+
* The payload carries no conversation data and no identifiers beyond a random
|
|
8
|
+
* UUID generated in localStorage; the server hashes it with a deployment salt
|
|
9
|
+
* before storage and never persists IP addresses. Sends are fire-and-forget:
|
|
10
|
+
* failures stay silent and simply retry on a later mount or day. The system
|
|
11
|
+
* is documented in docs/telemetry.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TelemetryChannel = 'market' | 'npm' | 'unknown'
|
|
15
|
+
|
|
16
|
+
export interface TelemetryItem {
|
|
17
|
+
/** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
|
|
18
|
+
name: string
|
|
19
|
+
/** Installed version when known; omitted otherwise. */
|
|
20
|
+
version?: string
|
|
21
|
+
/** Install channel when determinable (market = Workshop install). */
|
|
22
|
+
channel?: TelemetryChannel
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
|
|
26
|
+
const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
|
|
27
|
+
const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
|
|
28
|
+
|
|
29
|
+
// Baked into each client bundle by shared/tsdown.client.ts (the package's own
|
|
30
|
+
// package.json version at build time). Undefined in dev/test builds.
|
|
31
|
+
declare const __DSH_PKG_VERSION__: string | undefined
|
|
32
|
+
|
|
33
|
+
/** The building package's version, when the bundle carries it. */
|
|
34
|
+
function bakedVersion(): string | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
|
|
37
|
+
? __DSH_PKG_VERSION__
|
|
38
|
+
: undefined
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
|
|
45
|
+
function visitorId(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const existing = localStorage.getItem(VISITOR_KEY)
|
|
48
|
+
if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
|
|
49
|
+
const fresh = crypto.randomUUID().replaceAll('-', '')
|
|
50
|
+
localStorage.setItem(VISITOR_KEY, fresh)
|
|
51
|
+
return fresh
|
|
52
|
+
} catch {
|
|
53
|
+
// Storage unavailable (privacy mode, sandboxed frame): report nothing.
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop stale per-day dedup keys so localStorage does not grow forever. */
|
|
59
|
+
function pruneDayKeys(today: string): void {
|
|
60
|
+
try {
|
|
61
|
+
for (let index = localStorage.length - 1; index >= 0; index -= 1) {
|
|
62
|
+
const key = localStorage.key(index)
|
|
63
|
+
if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
|
|
64
|
+
localStorage.removeItem(key)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch { /* best effort */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fire the daily heartbeat for the given items at most once per UTC day per
|
|
72
|
+
* browser. Never throws and never blocks the caller. Items without an explicit
|
|
73
|
+
* version inherit the bundle's baked build version.
|
|
74
|
+
*/
|
|
75
|
+
export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
|
|
76
|
+
try {
|
|
77
|
+
if (items.length === 0) return
|
|
78
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
79
|
+
// Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
|
|
80
|
+
// mint a new visitor id per run and would inflate instance counts.
|
|
81
|
+
if (navigator.webdriver) return
|
|
82
|
+
if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
|
|
83
|
+
const visitor = visitorId()
|
|
84
|
+
if (visitor === null) return
|
|
85
|
+
pruneDayKeys(today)
|
|
86
|
+
const payloadItems = items.map((item) => {
|
|
87
|
+
const out: Record<string, string> = { name: item.name }
|
|
88
|
+
const version = item.version ?? bakedVersion()
|
|
89
|
+
if (version !== undefined) out.version = version
|
|
90
|
+
if (item.channel !== undefined) out.channel = item.channel
|
|
91
|
+
return out
|
|
92
|
+
})
|
|
93
|
+
const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
|
|
94
|
+
void fetch(ENDPOINT, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body,
|
|
98
|
+
keepalive: true,
|
|
99
|
+
}).then((response) => {
|
|
100
|
+
// Mark the day only after an accepted send, so offline browsers retry
|
|
101
|
+
// on a later mount instead of going dark until tomorrow.
|
|
102
|
+
if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
|
|
103
|
+
}).catch(() => { /* unreachable endpoint: stay silent */ })
|
|
104
|
+
} catch { /* never break the host UI over telemetry */ }
|
|
105
|
+
}
|
package/src/core/controller.ts
CHANGED
|
@@ -273,13 +273,21 @@ export class BoardController {
|
|
|
273
273
|
: undefined
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Apply an editable-field patch (task content + execution targets).
|
|
278
|
+
* Host-backed: the Host ledger owns the fail-closed checks (the content of
|
|
279
|
+
* an executed task is read-only) and confirms the mutation, so the resolved
|
|
280
|
+
* value reflects whether the Host accepted it; the legacy in-memory path
|
|
281
|
+
* applies and persists synchronously.
|
|
282
|
+
* @returns true when the patch was accepted by the authority.
|
|
283
|
+
*/
|
|
284
|
+
async updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean> {
|
|
277
285
|
if (this.deps.transport !== undefined) {
|
|
278
|
-
|
|
279
|
-
return
|
|
286
|
+
return await this.commitRemote({ kind: 'update', taskId: id, patch }, id)
|
|
280
287
|
}
|
|
281
288
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
282
289
|
this.persistAndNotify()
|
|
290
|
+
return true
|
|
283
291
|
}
|
|
284
292
|
|
|
285
293
|
/**
|
|
@@ -4,15 +4,38 @@
|
|
|
4
4
|
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
5
5
|
* controller orchestrates those).
|
|
6
6
|
*
|
|
7
|
-
* An explicit `undefined` in the patch clears
|
|
8
|
-
* back to the runtime default);
|
|
9
|
-
*
|
|
7
|
+
* An explicit `undefined` in the patch clears an execution-target field
|
|
8
|
+
* (the task falls back to the runtime default); content fields are trimmed
|
|
9
|
+
* and never cleared. An unknown permission string is ignored so stale UI
|
|
10
|
+
* can never persist a value the execution service rejects.
|
|
10
11
|
*/
|
|
11
12
|
import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
12
13
|
|
|
13
14
|
/** Editable fields on a task (the update patch surface). */
|
|
14
15
|
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The fields that edit the task's content (what the user reads and what the
|
|
19
|
+
* next execution sends). Unlike the execution targets they stay editable only
|
|
20
|
+
* while the task has never started executing — after the first run the
|
|
21
|
+
* recorded prompt is the record of what actually ran, so it becomes read-only.
|
|
22
|
+
*/
|
|
23
|
+
export const TASK_CONTENT_FIELDS = ['title', 'description', 'prompt'] as const
|
|
24
|
+
|
|
25
|
+
/** Whether an update patch touches any task-content field. */
|
|
26
|
+
export function hasContentPatch(patch: TaskUpdatePatch): boolean {
|
|
27
|
+
return (TASK_CONTENT_FIELDS as readonly string[]).some(field => field in patch)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a task's content may still be edited: the task must be on-board
|
|
32
|
+
* (not archived) and must never have started executing. Fail-closed: a
|
|
33
|
+
* running, settled, or cancelled-before-launch task keeps its content fixed.
|
|
34
|
+
*/
|
|
35
|
+
export function canEditTaskContent(task: TaskRecord): boolean {
|
|
36
|
+
return task.archivedAt === undefined && task.status !== 'running' && task.executions.length === 0
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
/** Keep an unknown permission string from entering the ledger. */
|
|
17
40
|
function normalizePermission(
|
|
18
41
|
current: TaskPermission | undefined,
|
|
@@ -42,6 +65,13 @@ export function applyUpdateTask(
|
|
|
42
65
|
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
43
66
|
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
44
67
|
const next: TaskRecord = { ...task, ...patch, updatedAt: now }
|
|
68
|
+
// Content fields normalize like creation does (trimmed); an explicit
|
|
69
|
+
// undefined keeps the current value — content cannot be cleared.
|
|
70
|
+
for (const field of TASK_CONTENT_FIELDS) {
|
|
71
|
+
if (!(field in patch)) continue
|
|
72
|
+
const value = patch[field]
|
|
73
|
+
next[field] = value === undefined ? task[field] : value.trim()
|
|
74
|
+
}
|
|
45
75
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
46
76
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
47
77
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/dsh-home.ts
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { isAbsolute, join } from 'node:path'
|
|
10
|
+
import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
|
|
10
11
|
|
|
11
12
|
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
12
13
|
export function expandHome(path: string, home: string = homedir()): string {
|
|
14
|
+
const isPosix = home.startsWith('/')
|
|
15
|
+
const j = isPosix ? posixJoin : join
|
|
13
16
|
if (path === '~') return home
|
|
14
|
-
if (path.startsWith('~/') || path.startsWith('~\\')) return
|
|
17
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
|
|
15
18
|
return path
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
|
|
|
22
25
|
* @returns the absolute DSH home path.
|
|
23
26
|
*/
|
|
24
27
|
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
28
|
+
const isPosix = home.startsWith('/')
|
|
29
|
+
const j = isPosix ? posixJoin : join
|
|
30
|
+
const isAbs = isPosix ? posixIsAbsolute : isAbsolute
|
|
25
31
|
const raw = env.DSH_HOME
|
|
26
32
|
if (raw !== undefined && raw.trim() !== '') {
|
|
27
33
|
const expanded = expandHome(raw.trim(), home)
|
|
28
|
-
return
|
|
34
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
|
|
29
35
|
}
|
|
30
|
-
return
|
|
36
|
+
return j(home, '.dsh')
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Resolve the DSH home directory from the live environment. */
|
package/src/host-ledger.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archiv
|
|
|
10
10
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
11
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
12
12
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
13
|
-
import { applyUpdateTask } from './core/use-cases/task-update.ts'
|
|
13
|
+
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
14
14
|
import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
15
15
|
|
|
16
16
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
@@ -482,6 +482,14 @@ export class HostTaskLedger {
|
|
|
482
482
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
483
483
|
if (task === undefined) throw new Error('task not found')
|
|
484
484
|
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
485
|
+
// The task content (title/description/prompt) is the record of what
|
|
486
|
+
// was planned; once an execution started it must not change under a
|
|
487
|
+
// running session or an executed history. Execution targets stay
|
|
488
|
+
// editable (they only affect future runs).
|
|
489
|
+
if (hasContentPatch(action.patch) && !canEditTaskContent(task)) {
|
|
490
|
+
throw new Error('task has already been executed')
|
|
491
|
+
}
|
|
492
|
+
if ('title' in action.patch && action.patch.title?.trim() === '') throw new Error('title is required')
|
|
485
493
|
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)]
|
|
486
494
|
break
|
|
487
495
|
}
|
package/src/http.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
12
|
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
13
|
* belong in the same change as its introduction.
|
|
14
|
-
* @module dsh-web-
|
|
14
|
+
* @module dsh-web-shared/host/http
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
|
|
|
27
27
|
export const inject = ['systemPrompt', 'apiProxy', 'webServer', 'agents', 'commands']
|
|
28
28
|
|
|
29
29
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
30
|
-
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web
|
|
30
|
+
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Settings namespace of the board's announcement capability — the section the
|
package/src/loopback.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
9
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
10
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
-
* @module dsh-web-
|
|
11
|
+
* @module dsh-web-shared/host/loopback
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { IncomingMessage } from 'node:http'
|
package/src/mount-once.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
2
|
/**
|
|
3
3
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
-
* (dsh-web-
|
|
4
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const MOUNTED = Symbol.for('dsh-web
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
19
|
|
|
20
20
|
interface MountRegistry {
|
|
21
21
|
[MOUNTED]?: Set<string>
|