@linxin666/dsh-client-ui-task-board 0.3.3 → 0.3.5

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.
@@ -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 the field (the task falls
8
- * back to the runtime default); an unknown permission string is ignored so
9
- * stale UI can never persist a value the execution service rejects.
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 join(home, path.slice(2))
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 isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
34
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
29
35
  }
30
- return join(home, '.dsh')
36
+ return j(home, '.dsh')
31
37
  }
32
38
 
33
39
  /** Resolve the DSH home directory from the live environment. */
@@ -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
  }