@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/client.js +366 -187
- package/lib/client.js.map +1 -1
- package/lib/index.js +42 -6
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/board/EditTaskModal.d.ts +8 -0
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/core/controller.d.ts +9 -1
- package/lib/types/core/use-cases/task-update.d.ts +19 -3
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/board/EditTaskModal.tsx +94 -0
- package/src/client/board/TaskDetail.tsx +19 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-card.module.css +5 -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/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
|
|
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
|
|
204
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
|
|
200
205
|
}
|
|
201
|
-
return
|
|
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
|
|
615
|
-
* back to the runtime default);
|
|
616
|
-
*
|
|
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
|
-
|
|
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
|
|
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 { 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.
|
|
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
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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;
|
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
|
}
|