@linxin666/dsh-client-ui-task-board 0.1.19 → 0.1.20
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 +25 -31
- package/lib/client.js.map +1 -1
- package/lib/index.js +43 -1
- package/lib/types/client/PluginSettingsCard.d.ts +1 -1
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/core/scheduler.d.ts +3 -0
- package/lib/types/core/tasks.d.ts +3 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +1 -1
- package/src/client/board/TaskBoard.tsx +1 -9
- package/src/client/board/TaskDetail.tsx +1 -9
- package/src/client/board/status-key.ts +11 -0
- package/src/core/scheduler.ts +12 -2
- package/src/core/store.ts +1 -6
- package/src/core/tasks.ts +1 -1
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/index.ts +4 -1
- package/src/mount-once.ts +48 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
|
+
//#region src/mount-once.ts
|
|
4
|
+
/**
|
|
5
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
6
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
7
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
8
|
+
* without this guard the second instance would still re-register the same
|
|
9
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
10
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
11
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
12
|
+
* package name in the client module host).
|
|
13
|
+
*
|
|
14
|
+
* The registry rides a global symbol so two module instances of the same
|
|
15
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
16
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
17
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
18
|
+
*/
|
|
19
|
+
const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
|
|
20
|
+
function mountedSet() {
|
|
21
|
+
const registry = globalThis;
|
|
22
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
26
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
27
|
+
* any later mount of the same package name is a no-op.
|
|
28
|
+
* @param packageName - npm package identity shared by every install source.
|
|
29
|
+
* @param fn - the original plugin apply.
|
|
30
|
+
* @returns an apply of the same shape.
|
|
31
|
+
*/
|
|
32
|
+
function mountOnce(packageName, fn) {
|
|
33
|
+
return ((...args) => {
|
|
34
|
+
const mounted = mountedSet();
|
|
35
|
+
if (mounted.has(packageName)) return;
|
|
36
|
+
mounted.add(packageName);
|
|
37
|
+
args[0]?.effect?.(() => () => {
|
|
38
|
+
mounted.delete(packageName);
|
|
39
|
+
});
|
|
40
|
+
return fn(...args);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
3
44
|
//#region src/index.ts
|
|
4
45
|
/** Order of the announcement section within the tool-guidance band. */
|
|
5
46
|
const SECTION_ORDER = 200;
|
|
@@ -26,7 +67,8 @@ const DEFAULT_ANNOUNCE = true;
|
|
|
26
67
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
27
68
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
28
69
|
*/
|
|
29
|
-
|
|
70
|
+
const apply = mountOnce("@linxin666/dsh-client-ui-task-board", applyImpl);
|
|
71
|
+
function applyImpl(ctx, config) {
|
|
30
72
|
let current = () => config ?? {};
|
|
31
73
|
let disposeSection;
|
|
32
74
|
const sync = () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { type ReactNode } from 'react';
|
|
10
10
|
import type { CardShell } from './settings-form.ts';
|
|
11
11
|
/** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
|
|
12
|
-
export declare const CARD_COPY_KEYS: readonly [
|
|
12
|
+
export declare const CARD_COPY_KEYS: readonly ['settings.collapse', 'settings.expand', 'settings.notExposed', 'settings.unsaved', 'settings.readOnly', 'settings.saveFailed', 'settings.discard', 'settings.save', 'settings.saving'];
|
|
13
13
|
/** Copy key the card chrome itself reads. */
|
|
14
14
|
export type CardCopyKey = (typeof CARD_COPY_KEYS)[number];
|
|
15
15
|
/** Key domain for the plugin's own copy (inferred per consumer). */
|
|
@@ -41,6 +41,9 @@ export declare class SchedulerService {
|
|
|
41
41
|
private environmentListener;
|
|
42
42
|
private disposed;
|
|
43
43
|
private started;
|
|
44
|
+
/** Cron expressions that matched no instant in the scan window, per task —
|
|
45
|
+
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
46
|
+
private readonly unmatchableRepair;
|
|
44
47
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
45
48
|
constructor(deps: SchedulerDeps);
|
|
46
49
|
/**
|
|
@@ -84,7 +84,7 @@ export interface TaskRecord {
|
|
|
84
84
|
/** Statuses a settled task may be archived from. */
|
|
85
85
|
export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
|
|
86
86
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
87
|
-
export declare const TASK_PERMISSIONS: readonly [
|
|
87
|
+
export declare const TASK_PERMISSIONS: readonly ['read-only', 'workspace-write', 'danger-full-access'];
|
|
88
88
|
/** One permission preset id. */
|
|
89
89
|
export type TaskPermission = typeof TASK_PERMISSIONS[number];
|
|
90
90
|
/** Whether an unknown value is a known permission preset id. */
|
|
@@ -125,6 +125,8 @@ export declare const ALL_STATUSES: readonly TaskStatus[];
|
|
|
125
125
|
export declare function isTaskStatus(value: unknown): value is TaskStatus;
|
|
126
126
|
/** Whether a manual move target is allowed from the given status. */
|
|
127
127
|
export declare function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean;
|
|
128
|
+
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
129
|
+
export declare function normalizeTargetId(value: string | undefined): string | undefined;
|
|
128
130
|
/** Create a task from user input. */
|
|
129
131
|
export declare function createTask(input: NewTaskInput, now: number, id: string): TaskRecord;
|
|
130
132
|
/** Clone a task with an updated status and a fresh updatedAt. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -41,4 +41,6 @@ export declare const Config: z<Config>;
|
|
|
41
41
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
42
42
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
43
43
|
*/
|
|
44
|
-
export declare
|
|
44
|
+
export declare const apply: typeof applyImpl;
|
|
45
|
+
declare function applyImpl(ctx: Context, config?: Config): void;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
4
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
5
|
+
* without this guard the second instance would still re-register the same
|
|
6
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
8
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
9
|
+
* package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* The registry rides a global symbol so two module instances of the same
|
|
12
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
13
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
19
|
+
* any later mount of the same package name is a no-op.
|
|
20
|
+
* @param packageName - npm package identity shared by every install source.
|
|
21
|
+
* @param fn - the original plugin apply.
|
|
22
|
+
* @returns an apply of the same shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -9,18 +9,10 @@ import { COLUMNS, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
|
9
9
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
12
|
+
import { STATUS_KEY } from './status-key.ts'
|
|
12
13
|
import { TaskCard } from './TaskCard.tsx'
|
|
13
14
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
14
15
|
|
|
15
|
-
/** Column status → locale key. */
|
|
16
|
-
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
17
|
-
backlog: 'board.status.backlog',
|
|
18
|
-
todo: 'board.status.todo',
|
|
19
|
-
running: 'board.status.running',
|
|
20
|
-
done: 'board.status.done',
|
|
21
|
-
failed: 'board.status.failed',
|
|
22
|
-
}
|
|
23
|
-
|
|
24
16
|
/** Case-insensitive title/description match. */
|
|
25
17
|
function matchesFilter(task: TaskRecord, filter: string): boolean {
|
|
26
18
|
if (filter.trim() === '') return true
|
|
@@ -13,6 +13,7 @@ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
15
15
|
import { formatTime } from './TaskCard.tsx'
|
|
16
|
+
import { STATUS_KEY } from './status-key.ts'
|
|
16
17
|
|
|
17
18
|
/** Execution outcome → locale key. */
|
|
18
19
|
const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
|
|
@@ -21,15 +22,6 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
21
22
|
cancelled: 'detail.result.cancelled',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** Status → locale key (detail badge). */
|
|
25
|
-
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
26
|
-
backlog: 'board.status.backlog',
|
|
27
|
-
todo: 'board.status.todo',
|
|
28
|
-
running: 'board.status.running',
|
|
29
|
-
done: 'board.status.done',
|
|
30
|
-
failed: 'board.status.failed',
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
/** One execution-history row. */
|
|
34
26
|
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
35
27
|
const result = execution.result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskStatus } from '../../core/tasks.ts'
|
|
2
|
+
import type { TaskBoardKey } from '../locales.ts'
|
|
3
|
+
|
|
4
|
+
/** Task status → locale key (board column titles and the detail badge). */
|
|
5
|
+
export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
6
|
+
backlog: 'board.status.backlog',
|
|
7
|
+
todo: 'board.status.todo',
|
|
8
|
+
running: 'board.status.running',
|
|
9
|
+
done: 'board.status.done',
|
|
10
|
+
failed: 'board.status.failed',
|
|
11
|
+
}
|
package/src/core/scheduler.ts
CHANGED
|
@@ -68,6 +68,9 @@ export class SchedulerService {
|
|
|
68
68
|
private environmentListener: (() => void) | undefined
|
|
69
69
|
private disposed = false
|
|
70
70
|
private started = false
|
|
71
|
+
/** Cron expressions that matched no instant in the scan window, per task —
|
|
72
|
+
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
73
|
+
private readonly unmatchableRepair = new Map<string, string>()
|
|
71
74
|
|
|
72
75
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
73
76
|
constructor(private readonly deps: SchedulerDeps) {}
|
|
@@ -133,9 +136,16 @@ export class SchedulerService {
|
|
|
133
136
|
if (schedule === undefined || !schedule.enabled) continue
|
|
134
137
|
if (schedule.nextRunAt === undefined) {
|
|
135
138
|
// Missing next-run instant (repaired/legacy data): recompute from the
|
|
136
|
-
// cron expression and wait; an unparseable expression is skipped.
|
|
139
|
+
// cron expression and wait; an unparseable expression is skipped. A
|
|
140
|
+
// grammatically valid rule with no match in the window (e.g. Feb 30)
|
|
141
|
+
// would otherwise re-run the full minute-by-minute scan every tick.
|
|
142
|
+
if (this.unmatchableRepair.get(task.id) === schedule.cron) continue
|
|
137
143
|
const repaired = nextRunAtMs(schedule.cron, now)
|
|
138
|
-
if (repaired === undefined)
|
|
144
|
+
if (repaired === undefined) {
|
|
145
|
+
this.unmatchableRepair.set(task.id, schedule.cron)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
this.unmatchableRepair.delete(task.id)
|
|
139
149
|
this.deps.applySchedule(task.id, repaired, undefined)
|
|
140
150
|
continue
|
|
141
151
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* localStorage backend.
|
|
15
15
|
*/
|
|
16
16
|
import { isValidCron } from './schedule.ts'
|
|
17
|
-
import { isTaskPermission, isTaskStatus, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
17
|
+
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
18
18
|
|
|
19
19
|
/** Persistence seam for the task ledger. */
|
|
20
20
|
export interface TaskStore {
|
|
@@ -80,11 +80,6 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
80
80
|
return true
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** Collapse a blank persisted target string to undefined (clears the pin). */
|
|
84
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
85
|
-
return value !== undefined && value.trim() === '' ? undefined : value
|
|
86
|
-
}
|
|
87
|
-
|
|
88
83
|
/** A task record is structurally valid if it round-trips through the UI. */
|
|
89
84
|
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
90
85
|
return isTaskRecordShape(value) && isTaskStatus(value.status)
|
package/src/core/tasks.ts
CHANGED
|
@@ -151,7 +151,7 @@ export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
154
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
154
|
+
export function normalizeTargetId(value: string | undefined): string | undefined {
|
|
155
155
|
const trimmed = value?.trim()
|
|
156
156
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
157
157
|
}
|
|
@@ -8,16 +8,11 @@
|
|
|
8
8
|
* back to the runtime default); an unknown permission string is ignored so
|
|
9
9
|
* stale UI can never persist a value the execution service rejects.
|
|
10
10
|
*/
|
|
11
|
-
import { isTaskPermission, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
11
|
+
import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
12
12
|
|
|
13
13
|
/** Editable fields on a task (the update patch surface). */
|
|
14
14
|
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
15
15
|
|
|
16
|
-
/** Collapse a blank target string to undefined (clears the pin). */
|
|
17
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
18
|
-
return value !== undefined && value.trim() === '' ? undefined : value
|
|
19
|
-
}
|
|
20
|
-
|
|
21
16
|
/** Keep an unknown permission string from entering the ledger. */
|
|
22
17
|
function normalizePermission(
|
|
23
18
|
current: TaskPermission | undefined,
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
15
15
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
16
|
import z from 'schemastery'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
18
|
+
import { mountOnce } from './mount-once.ts'
|
|
18
19
|
|
|
19
20
|
/** Order of the announcement section within the tool-guidance band. */
|
|
20
21
|
const SECTION_ORDER = 200
|
|
@@ -59,7 +60,9 @@ const DEFAULT_ANNOUNCE = true
|
|
|
59
60
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
60
61
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
61
62
|
*/
|
|
62
|
-
export
|
|
63
|
+
export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
|
|
64
|
+
|
|
65
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
63
66
|
// The live source the announcement reads: the settings section once the web
|
|
64
67
|
// settings surface is served, the composition entry otherwise
|
|
65
68
|
// (installSettingsSection swaps it when the namespace registers).
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|