@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/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
- function apply(ctx, config) {
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 ["settings.collapse", "settings.expand", "settings.notExposed", "settings.unsaved", "settings.readOnly", "settings.saveFailed", "settings.discard", "settings.save", "settings.saving"];
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). */
@@ -0,0 +1,4 @@
1
+ import type { TaskStatus } from '../../core/tasks.ts';
2
+ import type { TaskBoardKey } from '../locales.ts';
3
+ /** Task status → locale key (board column titles and the detail badge). */
4
+ export declare const STATUS_KEY: Record<TaskStatus, TaskBoardKey>;
@@ -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 ["read-only", "workspace-write", "danger-full-access"];
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. */
@@ -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 function apply(ctx: Context, config?: Config): void;
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.19",
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
+ }
@@ -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) continue
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 function apply(ctx: Context, config?: Config): void {
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
+ }