@linxin666/dsh-client-ui-task-board 0.3.3 → 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/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
5
5
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, win32 } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
9
  //#region src/core/schedule.ts
9
10
  /** Inclusive ranges per field, in cron order. */
10
11
  const FIELD_RANGES = [
@@ -182,8 +183,9 @@ function isDigits(value) {
182
183
  */
183
184
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
184
185
  function expandHome(path, home = homedir()) {
186
+ const j = home.startsWith("/") ? join$1 : join;
185
187
  if (path === "~") return home;
186
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
188
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
187
189
  return path;
188
190
  }
189
191
  /**
@@ -193,12 +195,15 @@ function expandHome(path, home = homedir()) {
193
195
  * @returns the absolute DSH home path.
194
196
  */
195
197
  function resolveDshHome(env = process.env, home = homedir()) {
198
+ const isPosix = home.startsWith("/");
199
+ const j = isPosix ? join$1 : join;
200
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
196
201
  const raw = env.DSH_HOME;
197
202
  if (raw !== void 0 && raw.trim() !== "") {
198
203
  const expanded = expandHome(raw.trim(), home);
199
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
204
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
200
205
  }
201
- return join(home, ".dsh");
206
+ return j(home, ".dsh");
202
207
  }
203
208
  /** Resolve the DSH home directory from the live environment. */
204
209
  function dshHome() {
@@ -611,10 +616,34 @@ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
611
616
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
612
617
  * controller orchestrates those).
613
618
  *
614
- * An explicit `undefined` in the patch clears the field (the task falls
615
- * back to the runtime default); an unknown permission string is ignored so
616
- * stale UI can never persist a value the execution service rejects.
619
+ * An explicit `undefined` in the patch clears an execution-target field
620
+ * (the task falls back to the runtime default); content fields are trimmed
621
+ * and never cleared. An unknown permission string is ignored so stale UI
622
+ * can never persist a value the execution service rejects.
617
623
  */
624
+ /**
625
+ * The fields that edit the task's content (what the user reads and what the
626
+ * next execution sends). Unlike the execution targets they stay editable only
627
+ * while the task has never started executing — after the first run the
628
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
629
+ */
630
+ const TASK_CONTENT_FIELDS = [
631
+ "title",
632
+ "description",
633
+ "prompt"
634
+ ];
635
+ /** Whether an update patch touches any task-content field. */
636
+ function hasContentPatch(patch) {
637
+ return TASK_CONTENT_FIELDS.some((field) => field in patch);
638
+ }
639
+ /**
640
+ * Whether a task's content may still be edited: the task must be on-board
641
+ * (not archived) and must never have started executing. Fail-closed: a
642
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
643
+ */
644
+ function canEditTaskContent(task) {
645
+ return task.archivedAt === void 0 && task.status !== "running" && task.executions.length === 0;
646
+ }
618
647
  /** Keep an unknown permission string from entering the ledger. */
619
648
  function normalizePermission(current, value) {
620
649
  if (value === void 0) return void 0;
@@ -639,6 +668,11 @@ function applyUpdateTask(tasks, id, patch, now) {
639
668
  ...patch,
640
669
  updatedAt: now
641
670
  };
671
+ for (const field of TASK_CONTENT_FIELDS) {
672
+ if (!(field in patch)) continue;
673
+ const value = patch[field];
674
+ next[field] = value === void 0 ? task[field] : value.trim();
675
+ }
642
676
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
643
677
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
644
678
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
@@ -1266,6 +1300,8 @@ var HostTaskLedger = class {
1266
1300
  const task = this.document.tasks.find((task) => task.id === action.taskId);
1267
1301
  if (task === void 0) throw new Error("task not found");
1268
1302
  if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
1303
+ if (hasContentPatch(action.patch) && !canEditTaskContent(task)) throw new Error("task has already been executed");
1304
+ if ("title" in action.patch && action.patch.title?.trim() === "") throw new Error("title is required");
1269
1305
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
1270
1306
  break;
1271
1307
  }
@@ -22,6 +22,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
22
22
  titleKey: TKey;
23
23
  /** Locale key of the line describing what this plugin's settings govern. */
24
24
  descriptionKey: TKey;
25
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
26
+ descriptionNode?: ReactNode;
25
27
  /** The card's form state: availability, writability, and what a save would do. */
26
28
  state: CardShell;
27
29
  /** Write every staged edit. */
@@ -0,0 +1,8 @@
1
+ import type { BoardController } from '../../core/controller.ts';
2
+ import type { TaskRecord } from '../../core/tasks.ts';
3
+ /** Edit-task form overlay. */
4
+ export declare function EditTaskModal({ controller, task, onClose }: {
5
+ controller: BoardController;
6
+ task: TaskRecord;
7
+ onClose: () => void;
8
+ }): import("react").JSX.Element;
@@ -40,7 +40,10 @@ export declare const zh: {
40
40
  'new.cancel': string;
41
41
  'new.required': string;
42
42
  'detail.title': string;
43
+ 'detail.edit': string;
43
44
  'detail.close': string;
45
+ 'edit.title': string;
46
+ 'edit.save': string;
44
47
  'detail.prompt': string;
45
48
  'detail.description': string;
46
49
  'detail.execution': string;
@@ -125,7 +125,15 @@ export declare class BoardController {
125
125
  createTask(input: NewTaskInput): TaskRecord | undefined;
126
126
  /** Create through the Host and expose the task only after confirmation. */
127
127
  createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined>;
128
- updateTask(id: string, patch: TaskUpdatePatch): void;
128
+ /**
129
+ * Apply an editable-field patch (task content + execution targets).
130
+ * Host-backed: the Host ledger owns the fail-closed checks (the content of
131
+ * an executed task is read-only) and confirms the mutation, so the resolved
132
+ * value reflects whether the Host accepted it; the legacy in-memory path
133
+ * applies and persists synchronously.
134
+ * @returns true when the patch was accepted by the authority.
135
+ */
136
+ updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean>;
129
137
  /**
130
138
  * Replace (a part of) the picker option sets the UI feeds (workspace list
131
139
  * and agent-preset roster come from the runtime, not the ledger).
@@ -4,13 +4,29 @@
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 { type TaskRecord } from '../tasks.ts';
12
13
  /** Editable fields on a task (the update patch surface). */
13
14
  export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>;
15
+ /**
16
+ * The fields that edit the task's content (what the user reads and what the
17
+ * next execution sends). Unlike the execution targets they stay editable only
18
+ * while the task has never started executing — after the first run the
19
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
20
+ */
21
+ export declare const TASK_CONTENT_FIELDS: readonly ["title", "description", "prompt"];
22
+ /** Whether an update patch touches any task-content field. */
23
+ export declare function hasContentPatch(patch: TaskUpdatePatch): boolean;
24
+ /**
25
+ * Whether a task's content may still be edited: the task must be on-board
26
+ * (not archived) and must never have started executing. Fail-closed: a
27
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
28
+ */
29
+ export declare function canEditTaskContent(task: TaskRecord): boolean;
14
30
  /**
15
31
  * Apply an update across the ledger. Tasks that do not match the id are left
16
32
  * untouched; the matched task receives the patch plus a fresh updatedAt.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.3",
4
+ "version": "0.3.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
39
39
  titleKey: TKey
40
40
  /** Locale key of the line describing what this plugin's settings govern. */
41
41
  descriptionKey: TKey
42
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
+ descriptionNode?: ReactNode
42
44
  /** The card's form state: availability, writability, and what a save would do. */
43
45
  state: CardShell
44
46
  /** Write every staged edit. */
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
87
89
  <div className={css.headerStatic}>
88
90
  <span className={css.headText}>
89
91
  <span className={css.name} title={title}>{title}</span>
90
- <span className={css.description} title={description}>{description}</span>
92
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
91
93
  </span>
92
94
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
93
95
  </div>
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
102
104
  >
103
105
  <span className={css.headText}>
104
106
  <span className={css.name} title={title}>{title}</span>
105
- <span className={css.description} title={description}>{description}</span>
107
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
106
108
  </span>
107
109
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
108
110
  <svg
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Edit-task modal: title + description + the prompt the next execution will
3
+ * send, pre-filled from the task. Shown only for tasks that have never
4
+ * started executing (the detail view gates on canEditTaskContent); the Host
5
+ * still re-checks at submit, so a task that started running while the modal
6
+ * was open fails closed and the error surfaces here.
7
+ */
8
+ import { useState } from 'react'
9
+ import type { BoardController } from '../../core/controller.ts'
10
+ import type { TaskRecord } from '../../core/tasks.ts'
11
+ import { t } from '../locales.ts'
12
+ import css from '../board.module.css'
13
+
14
+ /** Edit-task form overlay. */
15
+ export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
16
+ const [title, setTitle] = useState(task.title)
17
+ const [description, setDescription] = useState(task.description)
18
+ const [prompt, setPrompt] = useState(task.prompt)
19
+ const [error, setError] = useState<string | undefined>(undefined)
20
+ const [pending, setPending] = useState(false)
21
+
22
+ const submit = async (): Promise<void> => {
23
+ if (title.trim() === '') {
24
+ setError(t('new.required'))
25
+ return
26
+ }
27
+ setPending(true)
28
+ // The Host confirms the mutation (and its fail-closed checks); only a
29
+ // confirmed save closes the modal.
30
+ if (await controller.updateTask(task.id, { title, description, prompt })) {
31
+ onClose()
32
+ return
33
+ }
34
+ setPending(false)
35
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
36
+ }
37
+
38
+ return (
39
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
40
+ <form
41
+ className={css.modal}
42
+ role="dialog"
43
+ aria-label={t('edit.title')}
44
+ onSubmit={event => { event.preventDefault(); void submit() }}
45
+ >
46
+ <h2 className={css.modalTitle}>{t('edit.title')}</h2>
47
+
48
+ <label className={css.field}>
49
+ <span className={css.fieldLabel}>{t('new.title')}</span>
50
+ <input
51
+ className={css.input}
52
+ value={title}
53
+ autoFocus
54
+ placeholder={t('new.titlePlaceholder')}
55
+ onChange={event => { setTitle(event.target.value); setError(undefined) }}
56
+ />
57
+ </label>
58
+
59
+ <label className={css.field}>
60
+ <span className={css.fieldLabel}>{t('new.description')}</span>
61
+ <textarea
62
+ className={css.input}
63
+ rows={3}
64
+ value={description}
65
+ placeholder={t('new.descriptionPlaceholder')}
66
+ onChange={event => { setDescription(event.target.value) }}
67
+ />
68
+ </label>
69
+
70
+ <label className={css.field}>
71
+ <span className={css.fieldLabel}>{t('new.prompt')}</span>
72
+ <textarea
73
+ className={css.input}
74
+ rows={4}
75
+ value={prompt}
76
+ placeholder={t('new.promptPlaceholder')}
77
+ onChange={event => { setPrompt(event.target.value) }}
78
+ />
79
+ </label>
80
+
81
+ {error !== undefined && <p className={css.formError}>{error}</p>}
82
+
83
+ <footer className={css.modalFooter}>
84
+ <button type="button" className={css.ghostButton} onClick={onClose}>
85
+ {t('new.cancel')}
86
+ </button>
87
+ <button type="submit" className={css.primaryButton} disabled={pending}>
88
+ {t('edit.save')}
89
+ </button>
90
+ </footer>
91
+ </form>
92
+ </div>
93
+ )
94
+ }
@@ -8,10 +8,12 @@ import { useEffect, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
+ import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
11
12
  import { t, type TaskBoardKey } from '../locales.ts'
12
13
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
14
  import css from '../board.module.css'
14
15
  import { ConfirmDialog } from './ConfirmDialog.tsx'
16
+ import { EditTaskModal } from './EditTaskModal.tsx'
15
17
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
16
18
  import { STATUS_KEY } from './status-key.ts'
17
19
 
@@ -232,10 +234,13 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
234
  /** Task detail overlay. */
233
235
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
234
236
  const [confirmDelete, setConfirmDelete] = useState(false)
237
+ const [showEdit, setShowEdit] = useState(false)
235
238
 
236
239
  // Keep the overlay in sync if the task record changes underneath.
237
240
  const [latest, setLatest] = useState(task)
238
241
  useEffect(() => { setLatest(task) }, [task])
242
+ // A re-used overlay instance must not carry an edit session across tasks.
243
+ useEffect(() => { setShowEdit(false) }, [task.id])
239
244
  const current = latest
240
245
  const snapshot = controller.getSnapshot()
241
246
  const running = current.status === 'running'
@@ -328,6 +333,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
328
333
 
329
334
  <footer className={css.detailFooter}>
330
335
  {!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
336
+ {!archived && canEditTaskContent(current) && (
337
+ <button
338
+ type="button"
339
+ className={css.ghostButton}
340
+ disabled={pending}
341
+ onClick={() => { setShowEdit(true) }}
342
+ >
343
+ {t('detail.edit')}
344
+ </button>
345
+ )}
331
346
  {!archived && (
332
347
  <button
333
348
  type="button"
@@ -395,6 +410,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
395
410
  }}
396
411
  />
397
412
  )}
413
+
414
+ {showEdit && !archived && canEditTaskContent(current) && (
415
+ <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
416
+ )}
398
417
  </div>
399
418
  )
400
419
  }
@@ -41,7 +41,10 @@ export const zh = {
41
41
  'new.cancel': '取消',
42
42
  'new.required': '标题不能为空',
43
43
  'detail.title': '任务详情',
44
+ 'detail.edit': '编辑',
44
45
  'detail.close': '关闭',
46
+ 'edit.title': '编辑任务',
47
+ 'edit.save': '保存',
45
48
  'detail.prompt': '执行 Prompt',
46
49
  'detail.description': '描述',
47
50
  'detail.execution': '执行记录',
@@ -164,7 +167,10 @@ export const en: Record<keyof typeof zh, string> = {
164
167
  'new.cancel': 'Cancel',
165
168
  'new.required': 'Title is required',
166
169
  'detail.title': 'Task Detail',
170
+ 'detail.edit': 'Edit',
167
171
  'detail.close': 'Close',
172
+ 'edit.title': 'Edit Task',
173
+ 'edit.save': 'Save',
168
174
  'detail.prompt': 'Run Prompt',
169
175
  'detail.description': 'Description',
170
176
  'detail.execution': 'Execution History',
@@ -24,6 +24,10 @@
24
24
  .header {
25
25
  appearance: none;
26
26
  width: 100%;
27
+ /* The suite does not force a global border-box, so width:100% + padding
28
+ would stretch the row 32px past the card and push the trailing pending
29
+ badge (last flex item) outside the card border. */
30
+ box-sizing: border-box;
27
31
  font: inherit;
28
32
  color: inherit;
29
33
  text-align: left;
@@ -44,6 +48,7 @@
44
48
 
45
49
  .headerStatic {
46
50
  width: 100%;
51
+ box-sizing: border-box;
47
52
  border-radius: 12px;
48
53
  align-items: center;
49
54
  gap: 12px;
@@ -273,13 +273,21 @@ export class BoardController {
273
273
  : undefined
274
274
  }
275
275
 
276
- updateTask(id: string, patch: TaskUpdatePatch): void {
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
- void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
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 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
  }