dsh-taskboard 0.1.1 → 0.2.0

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.
@@ -142,6 +142,18 @@ function nextCronTime(match, from) {
142
142
  }
143
143
  return null;
144
144
  }
145
+ /**
146
+ * Enforce the execution-record retention cap on one task (in place): keep the
147
+ * newest {@link MAX_EXECUTIONS} records, count the dropped ones in
148
+ * `executionsPruned`. Running records are always the newest, never dropped.
149
+ * @param task - the task to prune.
150
+ */
151
+ function pruneExecutions(task) {
152
+ if (task.executions.length <= 20) return;
153
+ const dropped = task.executions.length - 20;
154
+ task.executions = task.executions.slice(-20);
155
+ task.executionsPruned = (task.executionsPruned ?? 0) + dropped;
156
+ }
145
157
  /** An empty ledger. */
146
158
  function emptyLedger() {
147
159
  return {
@@ -248,7 +260,46 @@ function effectivePrompt(task) {
248
260
  * @param task - the task.
249
261
  */
250
262
  function isClaimedBy(task) {
251
- return task.status === "in_progress" && task.updatedBy.kind === "agent" ? task.updatedBy.sessionId : void 0;
263
+ return task.status === "in_progress" && task.claimedBy !== void 0 ? task.claimedBy : void 0;
264
+ }
265
+ /**
266
+ * Maintain the explicit claim fields around a status change: entering
267
+ * in_progress under a session records the holder (an execution-start or an
268
+ * agent claim); every move out of in_progress releases the claim (handoff,
269
+ * give-back, cancel). A user-driven move into in_progress records no holder —
270
+ * no session works on it yet.
271
+ * @param task - the task being written (mutated in place).
272
+ * @param to - the target status.
273
+ * @param now - current epoch ms.
274
+ * @param holder - the session id claiming the task, when applicable.
275
+ */
276
+ function syncClaim(task, to, now, holder) {
277
+ if (to !== "in_progress") {
278
+ delete task.claimedBy;
279
+ delete task.claimedAt;
280
+ } else if (holder !== void 0) {
281
+ task.claimedBy = holder;
282
+ task.claimedAt = now;
283
+ }
284
+ }
285
+ /**
286
+ * Validate and normalize a pinned model: `{ provider, model }`, both
287
+ * non-empty trimmed strings.
288
+ * @param raw - the raw input.
289
+ * @returns the normalized model.
290
+ * @throws when the shape or the fields are invalid.
291
+ */
292
+ function normalizeModel(raw) {
293
+ if (typeof raw !== "object" || raw === null) throw new Error("model must be { provider: string, model: string }");
294
+ const { provider, model } = raw;
295
+ if (typeof provider !== "string" || typeof model !== "string") throw new Error("model must be { provider: string, model: string }");
296
+ const p = provider.trim();
297
+ const m = model.trim();
298
+ if (p.length === 0 || m.length === 0) throw new Error("model.provider and model.model must be non-empty strings");
299
+ return {
300
+ provider: p,
301
+ model: m
302
+ };
252
303
  }
253
304
  /**
254
305
  * Build the compact summary of a task.
@@ -274,6 +325,6 @@ function summarize(task) {
274
325
  };
275
326
  }
276
327
  //#endregion
277
- export { ALL_STATUSES, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asStatus, asUrgency, canTransition, effectivePrompt, emptyLedger, isClaim, isClaimedBy, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeExecution, normalizePrompt, normalizeTitle, parseCron, summarize };
328
+ export { ALL_STATUSES, MAIN_STATUSES, SECONDARY_STATUSES, URGENCIES, asStatus, asUrgency, canTransition, effectivePrompt, emptyLedger, isClaim, isClaimedBy, newCommentId, newExecutionId, newTaskId, nextCronTime, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, parseCron, pruneExecutions, summarize, syncClaim };
278
329
 
279
330
  //# sourceMappingURL=protocol.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","names":[],"sources":["../../src/shared/protocol.ts"],"sourcesContent":["/**\n * Task domain model, state machine, urgency classes, and cron math — the\n * framework-free core shared verbatim by the host half (tools, store, routes,\n * scheduler) and, from P2 on, the browser half (board view).\n *\n * Everything here is a pure function over plain data: no imports beyond the\n * standard library, no I/O, no globals. Tests drive it directly.\n *\n * @module dsh-taskboard/shared/protocol\n */\n\n// ---------------------------------------------------------------------------\n// Status vocabulary\n// ---------------------------------------------------------------------------\n\n/**\n * Task lifecycle states. Main board columns render `backlog → todo →\n * in_progress → in_review → done`; `canceled` and `archived` are secondary\n * states collected under an \"other tasks\" tab. `blocked` is NOT a status —\n * it is a horizontal marker any non-terminal state may carry.\n */\nexport type TaskStatus =\n | 'backlog'\n | 'todo'\n | 'in_progress'\n | 'in_review'\n | 'done'\n | 'canceled'\n | 'archived'\n\n/** Statuses shown as the five main board columns, in order. */\nexport const MAIN_STATUSES: readonly TaskStatus[] = [\n 'backlog',\n 'todo',\n 'in_progress',\n 'in_review',\n 'done',\n]\n\n/** Statuses collected under the secondary tab. */\nexport const SECONDARY_STATUSES: readonly TaskStatus[] = ['canceled', 'archived']\n\n/** Every valid status, main first. */\nexport const ALL_STATUSES: readonly TaskStatus[] = [...MAIN_STATUSES, ...SECONDARY_STATUSES]\n\n/**\n * Legal forward/sideways transitions. Anything not listed is rejected with\n * `invalid_transition`. `archived` is terminal.\n */\nconst TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {\n backlog: ['todo', 'canceled'],\n todo: ['in_progress', 'backlog', 'canceled'],\n in_progress: ['in_review', 'todo', 'canceled'],\n in_review: ['in_progress', 'todo', 'done', 'canceled'],\n done: ['archived'],\n canceled: ['archived', 'todo'],\n archived: [],\n}\n\n/**\n * Whether a status move is legal per the state machine.\n * @param from - current status.\n * @param to - requested status.\n * @returns true when the transition is allowed.\n */\nexport function canTransition(from: TaskStatus, to: TaskStatus): boolean {\n return TRANSITIONS[from].includes(to)\n}\n\n/**\n * The claim move: the one transition that transfers ownership of a task to\n * the calling session. Guarded by the project (workspace) boundary in the\n * tool layer.\n */\nexport function isClaim(from: TaskStatus, to: TaskStatus): boolean {\n return from === 'todo' && to === 'in_progress'\n}\n\n/** Statuses a `done` move may depart from (user confirmation only). */\nexport function canCompleteFrom(from: TaskStatus): boolean {\n return from === 'in_review'\n}\n\n// ---------------------------------------------------------------------------\n// Urgency\n// ---------------------------------------------------------------------------\n\n/** Urgency classes with fixed UI colors. */\nexport type Urgency = 'urgent' | 'normal' | 'relaxed'\n\n/** All valid urgency values. */\nexport const URGENCIES: readonly Urgency[] = ['urgent', 'normal', 'relaxed']\n\n/** CSS color token per urgency: red / purple / blue. */\nexport const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {\n urgent: '#e5484d',\n normal: '#8e4ec6',\n relaxed: '#3e63dd',\n}\n\n// ---------------------------------------------------------------------------\n// Execution\n// ---------------------------------------------------------------------------\n\n/** How a task may run. */\nexport type ExecutionMode = 'claim' | 'scheduled'\n\n/**\n * Per-task execution configuration. `claim` tasks wait for an in-project\n * session to claim them; `scheduled` tasks run on the host cron scheduler.\n */\nexport interface ExecutionConfig {\n mode: ExecutionMode\n /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */\n cron?: string\n /** Next due time (epoch ms); maintained by the host scheduler. */\n nextRunAt?: number\n /** Last time the scheduler triggered this task (epoch ms). */\n lastTriggeredAt?: number\n}\n\n/**\n * Parse a five-field cron expression. Supported field syntax: star, star/step\n * (`* / n` without spaces), a single number, an `a-b` range, and comma lists\n * of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).\n *\n * @param expr - the expression to parse.\n * @returns the match sets per field, or null when invalid.\n */\nexport function parseCron(expr: string): CronMatch | null {\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) return null\n const ranges: ReadonlyArray<readonly [number, number]> = [\n [0, 59],\n [0, 23],\n [1, 31],\n [1, 12],\n [0, 7],\n ]\n const sets: Array<Set<number>> = []\n for (let i = 0; i < 5; i++) {\n const [min, max] = ranges[i]!\n const set = new Set<number>()\n if (!parseCronField(fields[i]!, min, max, set)) return null\n sets.push(set)\n }\n const weekdays = new Set<number>()\n for (const day of sets[4]!) weekdays.add(day === 7 ? 0 : day)\n return { minutes: sets[0]!, hours: sets[1]!, days: sets[2]!, months: sets[3]!, weekdays }\n}\n\n/** Parsed cron field match sets. */\nexport type CronMatch = {\n minutes: ReadonlySet<number>\n hours: ReadonlySet<number>\n days: ReadonlySet<number>\n months: ReadonlySet<number>\n weekdays: ReadonlySet<number>\n}\n\n/** Parse one cron field into a match set; false on any syntax error. */\nfunction parseCronField(field: string, min: number, max: number, out: Set<number>): boolean {\n for (const part of field.split(',')) {\n const [range, stepRaw] = part.split('/')\n const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)\n if (!Number.isInteger(step) || step < 1) return false\n let lo: number\n let hi: number\n if (range === undefined || range === '') return false\n if (range === '*') {\n lo = min\n hi = max\n } else if (range.includes('-')) {\n const [a, b] = range.split('-')\n lo = Number.parseInt(a ?? '', 10)\n hi = Number.parseInt(b ?? '', 10)\n if (!Number.isInteger(lo) || !Number.isInteger(hi)) return false\n } else {\n lo = Number.parseInt(range, 10)\n if (!Number.isInteger(lo)) return false\n hi = stepRaw === undefined ? lo : max\n }\n if (lo < min || hi > max || lo > hi) return false\n for (let v = lo; v <= hi; v += step) out.add(v)\n }\n return out.size > 0\n}\n\n/**\n * The next time at or after `from` matching the cron sets (local time),\n * or null when no match exists within four years (e.g. Feb 30).\n * @param match - parsed cron sets.\n * @param from - epoch ms start point (inclusive match candidate).\n * @returns the next match's epoch ms, or null.\n */\nexport function nextCronTime(match: CronMatch, from: number): number | null {\n // Walk minute by minute from the next whole minute, capped at ~4 years.\n const start = new Date(from)\n start.setSeconds(0, 0)\n start.setMinutes(start.getMinutes() + 1)\n const cap = from + 4 * 366 * 24 * 60 * 60 * 1000\n let t = start.getTime()\n while (t <= cap) {\n const d = new Date(t)\n if (\n match.months.has(d.getMonth() + 1)\n && match.days.has(d.getDate())\n && match.weekdays.has(d.getDay())\n && match.hours.has(d.getHours())\n && match.minutes.has(d.getMinutes())\n ) {\n return t\n }\n t += 60_000\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Records\n// ---------------------------------------------------------------------------\n\n/** Who performed a write. */\nexport type Actor =\n | { kind: 'user' }\n | { kind: 'agent'; sessionId: string }\n\n/** A progress/report comment on a task. */\nexport type CommentRecord = {\n id: string\n /** Comment body (plain text; UI renders as pre-wrapped). */\n body: string\n /** Optimistic-concurrency version of this comment. */\n version: number\n createdAt: number\n /** The session that wrote this comment; absent for user-written ones. */\n threadId?: string\n}\n\n/** One execution attempt of a task. */\nexport type ExecutionRecord = {\n id: string\n /** The session this execution ran in; set once the session is really started. */\n sessionId?: string\n /** Trigger: manual button or the host scheduler. */\n trigger: 'manual' | 'scheduled'\n startedAt?: number\n endedAt?: number\n outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'\n error?: string\n}\n\n/** The per-model override a task may carry; absent = session default model. */\nexport type TaskModel = {\n provider: string\n model: string\n}\n\n/** One task on the board. */\nexport type TaskRecord = {\n id: string\n title: string\n description: string\n /** The prompt sent to a fresh session on execution; falls back to title+description. */\n prompt: string\n /** Owning project: a DSH workspace id. */\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n /** Horizontal marker: work cannot continue right now (any non-terminal status). */\n blocked: boolean\n execution: ExecutionConfig\n model?: TaskModel\n version: number\n createdAt: number\n updatedAt: number\n createdBy: Actor\n updatedBy: Actor\n comments: CommentRecord[]\n executions: ExecutionRecord[]\n /** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */\n trashedAt?: number\n}\n\n/** The whole durable ledger. */\nexport type TaskLedger = {\n schemaVersion: number\n /** Global monotonic revision; every mutation bumps it. */\n revision: number\n tasks: TaskRecord[]\n}\n\n/** Current ledger format version. */\nexport const LEDGER_SCHEMA_VERSION = 1\n\n/** An empty ledger. */\nexport function emptyLedger(): TaskLedger {\n return { schemaVersion: LEDGER_SCHEMA_VERSION, revision: 0, tasks: [] }\n}\n\n// ---------------------------------------------------------------------------\n// ids\n// ---------------------------------------------------------------------------\n\n/** Random base36 suffix. */\nfunction suffix(): string {\n return Math.random().toString(36).slice(2, 8)\n}\n\n/** Mint a task id. */\nexport function newTaskId(): string {\n return `t-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a comment id. */\nexport function newCommentId(): string {\n return `c-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint an execution id. */\nexport function newExecutionId(): string {\n return `e-${Date.now().toString(36)}-${suffix()}`\n}\n\n// ---------------------------------------------------------------------------\n// validation helpers (input shaping for tools and routes)\n// ---------------------------------------------------------------------------\n\n/**\n * Validate and normalize a title: trimmed, 1..200 chars.\n * @param raw - the raw input.\n * @returns the normalized title.\n * @throws when empty or too long.\n */\nexport function normalizeTitle(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 200) {\n throw new Error('title must be 1..200 characters')\n }\n return t\n}\n\n/**\n * Validate a task prompt: trimmed, at most 8000 chars; empty becomes ''.\n * @param raw - the raw input.\n */\nexport function normalizePrompt(raw: string | undefined): string {\n const t = (raw ?? '').trim()\n if (t.length > 8000) throw new Error('prompt must be at most 8000 characters')\n return t\n}\n\n/**\n * Validate and normalize a comment body: trimmed, 1..4000 chars.\n * @param raw - the raw input.\n */\nexport function normalizeBody(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 4000) {\n throw new Error('comment body must be 1..4000 characters')\n }\n return t\n}\n\n/**\n * Validate an urgency value.\n * @param raw - the raw input.\n */\nexport function asUrgency(raw: string): Urgency {\n if (!URGENCIES.includes(raw as Urgency)) {\n throw new Error(`urgency must be one of: ${URGENCIES.join(', ')}`)\n }\n return raw as Urgency\n}\n\n/**\n * Validate a status value.\n * @param raw - the raw input.\n */\nexport function asStatus(raw: string): TaskStatus {\n if (!ALL_STATUSES.includes(raw as TaskStatus)) {\n throw new Error(`status must be one of: ${ALL_STATUSES.join(', ')}`)\n }\n return raw as TaskStatus\n}\n\n/**\n * Validate an execution config request from raw tool/route input.\n * `scheduled` requires a valid cron; computes the first `nextRunAt` from\n * `now`.\n * @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).\n * @param now - current epoch ms.\n * @returns the normalized config.\n */\nexport function normalizeExecution(\n raw: { mode?: string; cron?: string },\n now: number,\n): ExecutionConfig {\n const mode = raw.mode ?? 'claim'\n if (mode !== 'claim' && mode !== 'scheduled') {\n throw new Error(\"execution.mode must be 'claim' or 'scheduled'\")\n }\n if (mode === 'claim') return { mode }\n const cron = (raw.cron ?? '').trim()\n const match = parseCron(cron)\n if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')\n const next = nextCronTime(match, now)\n if (next === null) throw new Error('execution.cron never matches within 4 years')\n return { mode, cron, nextRunAt: next }\n}\n\n/**\n * The effective prompt of a task: explicit prompt, or title+description.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n if (task.prompt.length > 0) return task.prompt\n const head = task.title\n return task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\n}\n\n/**\n * Whether the task is currently claimed by a session (running state).\n * @param task - the task.\n */\nexport function isClaimedBy(task: TaskRecord): string | undefined {\n return task.status === 'in_progress' && task.updatedBy.kind === 'agent'\n ? task.updatedBy.sessionId\n : undefined\n}\n\n/**\n * Compact list-projection of a task (token-friendly for `taskboard_list`).\n * @param task - the task.\n */\nexport type TaskSummary = {\n id: string\n title: string\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n blocked: boolean\n executionMode: ExecutionMode\n nextRunAt?: number\n model?: TaskModel\n version: number\n claimOwner?: string\n commentCount: number\n lastExecutionOutcome?: ExecutionRecord['outcome']\n trashed: boolean\n}\n\n/**\n * Build the compact summary of a task.\n * @param task - the task.\n */\nexport function summarize(task: TaskRecord): TaskSummary {\n const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined\n return {\n id: task.id,\n title: task.title,\n workspaceId: task.workspaceId,\n urgency: task.urgency,\n status: task.status,\n blocked: task.blocked,\n executionMode: task.execution.mode,\n nextRunAt: task.execution.nextRunAt,\n model: task.model,\n version: task.version,\n claimOwner: isClaimedBy(task),\n commentCount: task.comments.length,\n lastExecutionOutcome: last?.outcome,\n trashed: task.trashedAt !== undefined,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"protocol.js","names":[],"sources":["../../src/shared/protocol.ts"],"sourcesContent":["/**\n * Task domain model, state machine, urgency classes, and cron math — the\n * framework-free core shared verbatim by the host half (tools, store, routes,\n * scheduler) and, from P2 on, the browser half (board view).\n *\n * Everything here is a pure function over plain data: no imports beyond the\n * standard library, no I/O, no globals. Tests drive it directly.\n *\n * @module dsh-taskboard/shared/protocol\n */\n\n// ---------------------------------------------------------------------------\n// Status vocabulary\n// ---------------------------------------------------------------------------\n\n/**\n * Task lifecycle states. Main board columns render `backlog → todo →\n * in_progress → in_review → done`; `canceled` and `archived` are secondary\n * states collected under an \"other tasks\" tab. `blocked` is NOT a status —\n * it is a horizontal marker any non-terminal state may carry.\n */\nexport type TaskStatus =\n | 'backlog'\n | 'todo'\n | 'in_progress'\n | 'in_review'\n | 'done'\n | 'canceled'\n | 'archived'\n\n/** Statuses shown as the five main board columns, in order. */\nexport const MAIN_STATUSES: readonly TaskStatus[] = [\n 'backlog',\n 'todo',\n 'in_progress',\n 'in_review',\n 'done',\n]\n\n/** Statuses collected under the secondary tab. */\nexport const SECONDARY_STATUSES: readonly TaskStatus[] = ['canceled', 'archived']\n\n/** Every valid status, main first. */\nexport const ALL_STATUSES: readonly TaskStatus[] = [...MAIN_STATUSES, ...SECONDARY_STATUSES]\n\n/**\n * Legal forward/sideways transitions. Anything not listed is rejected with\n * `invalid_transition`. `archived` is terminal.\n */\nconst TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {\n backlog: ['todo', 'canceled'],\n todo: ['in_progress', 'backlog', 'canceled'],\n in_progress: ['in_review', 'todo', 'canceled'],\n in_review: ['in_progress', 'todo', 'done', 'canceled'],\n done: ['archived'],\n canceled: ['archived', 'todo'],\n archived: [],\n}\n\n/**\n * Whether a status move is legal per the state machine.\n * @param from - current status.\n * @param to - requested status.\n * @returns true when the transition is allowed.\n */\nexport function canTransition(from: TaskStatus, to: TaskStatus): boolean {\n return TRANSITIONS[from].includes(to)\n}\n\n/**\n * The claim move: the one transition that transfers ownership of a task to\n * the calling session. Guarded by the project (workspace) boundary in the\n * tool layer.\n */\nexport function isClaim(from: TaskStatus, to: TaskStatus): boolean {\n return from === 'todo' && to === 'in_progress'\n}\n\n/** Statuses a `done` move may depart from (user confirmation only). */\nexport function canCompleteFrom(from: TaskStatus): boolean {\n return from === 'in_review'\n}\n\n// ---------------------------------------------------------------------------\n// Urgency\n// ---------------------------------------------------------------------------\n\n/** Urgency classes with fixed UI colors. */\nexport type Urgency = 'urgent' | 'normal' | 'relaxed'\n\n/** All valid urgency values. */\nexport const URGENCIES: readonly Urgency[] = ['urgent', 'normal', 'relaxed']\n\n/** CSS color token per urgency: red / purple / blue. */\nexport const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {\n urgent: '#e5484d',\n normal: '#8e4ec6',\n relaxed: '#3e63dd',\n}\n\n// ---------------------------------------------------------------------------\n// Execution\n// ---------------------------------------------------------------------------\n\n/** How a task may run. */\nexport type ExecutionMode = 'claim' | 'scheduled'\n\n/**\n * Per-task execution configuration. `claim` tasks wait for an in-project\n * session to claim them; `scheduled` tasks run on the host cron scheduler.\n */\nexport interface ExecutionConfig {\n mode: ExecutionMode\n /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */\n cron?: string\n /** Next due time (epoch ms); maintained by the host scheduler. */\n nextRunAt?: number\n /** Last time the scheduler triggered this task (epoch ms). */\n lastTriggeredAt?: number\n}\n\n/**\n * Parse a five-field cron expression. Supported field syntax: star, star/step\n * (`* / n` without spaces), a single number, an `a-b` range, and comma lists\n * of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).\n *\n * @param expr - the expression to parse.\n * @returns the match sets per field, or null when invalid.\n */\nexport function parseCron(expr: string): CronMatch | null {\n const fields = expr.trim().split(/\\s+/)\n if (fields.length !== 5) return null\n const ranges: ReadonlyArray<readonly [number, number]> = [\n [0, 59],\n [0, 23],\n [1, 31],\n [1, 12],\n [0, 7],\n ]\n const sets: Array<Set<number>> = []\n for (let i = 0; i < 5; i++) {\n const [min, max] = ranges[i]!\n const set = new Set<number>()\n if (!parseCronField(fields[i]!, min, max, set)) return null\n sets.push(set)\n }\n const weekdays = new Set<number>()\n for (const day of sets[4]!) weekdays.add(day === 7 ? 0 : day)\n return { minutes: sets[0]!, hours: sets[1]!, days: sets[2]!, months: sets[3]!, weekdays }\n}\n\n/** Parsed cron field match sets. */\nexport type CronMatch = {\n minutes: ReadonlySet<number>\n hours: ReadonlySet<number>\n days: ReadonlySet<number>\n months: ReadonlySet<number>\n weekdays: ReadonlySet<number>\n}\n\n/** Parse one cron field into a match set; false on any syntax error. */\nfunction parseCronField(field: string, min: number, max: number, out: Set<number>): boolean {\n for (const part of field.split(',')) {\n const [range, stepRaw] = part.split('/')\n const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)\n if (!Number.isInteger(step) || step < 1) return false\n let lo: number\n let hi: number\n if (range === undefined || range === '') return false\n if (range === '*') {\n lo = min\n hi = max\n } else if (range.includes('-')) {\n const [a, b] = range.split('-')\n lo = Number.parseInt(a ?? '', 10)\n hi = Number.parseInt(b ?? '', 10)\n if (!Number.isInteger(lo) || !Number.isInteger(hi)) return false\n } else {\n lo = Number.parseInt(range, 10)\n if (!Number.isInteger(lo)) return false\n hi = stepRaw === undefined ? lo : max\n }\n if (lo < min || hi > max || lo > hi) return false\n for (let v = lo; v <= hi; v += step) out.add(v)\n }\n return out.size > 0\n}\n\n/**\n * The next time at or after `from` matching the cron sets (local time),\n * or null when no match exists within four years (e.g. Feb 30).\n * @param match - parsed cron sets.\n * @param from - epoch ms start point (inclusive match candidate).\n * @returns the next match's epoch ms, or null.\n */\nexport function nextCronTime(match: CronMatch, from: number): number | null {\n // Walk minute by minute from the next whole minute, capped at ~4 years.\n const start = new Date(from)\n start.setSeconds(0, 0)\n start.setMinutes(start.getMinutes() + 1)\n const cap = from + 4 * 366 * 24 * 60 * 60 * 1000\n let t = start.getTime()\n while (t <= cap) {\n const d = new Date(t)\n if (\n match.months.has(d.getMonth() + 1)\n && match.days.has(d.getDate())\n && match.weekdays.has(d.getDay())\n && match.hours.has(d.getHours())\n && match.minutes.has(d.getMinutes())\n ) {\n return t\n }\n t += 60_000\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Records\n// ---------------------------------------------------------------------------\n\n/** Who performed a write. */\nexport type Actor =\n | { kind: 'user' }\n | { kind: 'agent'; sessionId: string }\n\n/** A progress/report comment on a task. */\nexport type CommentRecord = {\n id: string\n /** Comment body (plain text; UI renders as pre-wrapped). */\n body: string\n /** Optimistic-concurrency version of this comment. */\n version: number\n createdAt: number\n /** The session that wrote this comment; absent for user-written ones. */\n threadId?: string\n}\n\n/** One execution attempt of a task. */\nexport type ExecutionRecord = {\n id: string\n /** The session this execution ran in; set once the session is really started. */\n sessionId?: string\n /** Trigger: manual button or the host scheduler. */\n trigger: 'manual' | 'scheduled'\n startedAt?: number\n endedAt?: number\n outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'\n error?: string\n}\n\n/** The per-model override a task may carry; absent = session default model. */\nexport type TaskModel = {\n provider: string\n model: string\n}\n\n/** One task on the board. */\nexport type TaskRecord = {\n id: string\n title: string\n description: string\n /** The prompt sent to a fresh session on execution; falls back to title+description. */\n prompt: string\n /** Owning project: a DSH workspace id. */\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n /** Horizontal marker: work cannot continue right now (any non-terminal status). */\n blocked: boolean\n execution: ExecutionConfig\n model?: TaskModel\n /**\n * The session currently holding the in-progress claim (explicit claim or a\n * live execution). Present only while `status === 'in_progress'`: any move\n * out of in_progress releases it. `updatedBy` is audit-only — user edits no\n * longer erase the holder.\n */\n claimedBy?: string\n /** When the current holder claimed the task (epoch ms). */\n claimedAt?: number\n version: number\n createdAt: number\n updatedAt: number\n createdBy: Actor\n updatedBy: Actor\n comments: CommentRecord[]\n executions: ExecutionRecord[]\n /** How many older execution records were pruned by the retention cap. */\n executionsPruned?: number\n /** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */\n trashedAt?: number\n}\n\n/** Retention cap: how many execution records each task keeps (oldest pruned). */\nexport const MAX_EXECUTIONS = 20\n\n/**\n * Enforce the execution-record retention cap on one task (in place): keep the\n * newest {@link MAX_EXECUTIONS} records, count the dropped ones in\n * `executionsPruned`. Running records are always the newest, never dropped.\n * @param task - the task to prune.\n */\nexport function pruneExecutions(task: TaskRecord): void {\n if (task.executions.length <= MAX_EXECUTIONS) return\n const dropped = task.executions.length - MAX_EXECUTIONS\n task.executions = task.executions.slice(-MAX_EXECUTIONS)\n task.executionsPruned = (task.executionsPruned ?? 0) + dropped\n}\n\n/** The whole durable ledger. */\nexport type TaskLedger = {\n schemaVersion: number\n /** Global monotonic revision; every mutation bumps it. */\n revision: number\n tasks: TaskRecord[]\n}\n\n/** Current ledger format version. */\nexport const LEDGER_SCHEMA_VERSION = 1\n\n/** An empty ledger. */\nexport function emptyLedger(): TaskLedger {\n return { schemaVersion: LEDGER_SCHEMA_VERSION, revision: 0, tasks: [] }\n}\n\n// ---------------------------------------------------------------------------\n// ids\n// ---------------------------------------------------------------------------\n\n/** Random base36 suffix. */\nfunction suffix(): string {\n return Math.random().toString(36).slice(2, 8)\n}\n\n/** Mint a task id. */\nexport function newTaskId(): string {\n return `t-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint a comment id. */\nexport function newCommentId(): string {\n return `c-${Date.now().toString(36)}-${suffix()}`\n}\n\n/** Mint an execution id. */\nexport function newExecutionId(): string {\n return `e-${Date.now().toString(36)}-${suffix()}`\n}\n\n// ---------------------------------------------------------------------------\n// validation helpers (input shaping for tools and routes)\n// ---------------------------------------------------------------------------\n\n/**\n * Validate and normalize a title: trimmed, 1..200 chars.\n * @param raw - the raw input.\n * @returns the normalized title.\n * @throws when empty or too long.\n */\nexport function normalizeTitle(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 200) {\n throw new Error('title must be 1..200 characters')\n }\n return t\n}\n\n/**\n * Validate a task prompt: trimmed, at most 8000 chars; empty becomes ''.\n * @param raw - the raw input.\n */\nexport function normalizePrompt(raw: string | undefined): string {\n const t = (raw ?? '').trim()\n if (t.length > 8000) throw new Error('prompt must be at most 8000 characters')\n return t\n}\n\n/**\n * Validate and normalize a comment body: trimmed, 1..4000 chars.\n * @param raw - the raw input.\n */\nexport function normalizeBody(raw: string): string {\n const t = raw.trim()\n if (t.length === 0 || t.length > 4000) {\n throw new Error('comment body must be 1..4000 characters')\n }\n return t\n}\n\n/**\n * Validate an urgency value.\n * @param raw - the raw input.\n */\nexport function asUrgency(raw: string): Urgency {\n if (!URGENCIES.includes(raw as Urgency)) {\n throw new Error(`urgency must be one of: ${URGENCIES.join(', ')}`)\n }\n return raw as Urgency\n}\n\n/**\n * Validate a status value.\n * @param raw - the raw input.\n */\nexport function asStatus(raw: string): TaskStatus {\n if (!ALL_STATUSES.includes(raw as TaskStatus)) {\n throw new Error(`status must be one of: ${ALL_STATUSES.join(', ')}`)\n }\n return raw as TaskStatus\n}\n\n/**\n * Validate an execution config request from raw tool/route input.\n * `scheduled` requires a valid cron; computes the first `nextRunAt` from\n * `now`.\n * @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).\n * @param now - current epoch ms.\n * @returns the normalized config.\n */\nexport function normalizeExecution(\n raw: { mode?: string; cron?: string },\n now: number,\n): ExecutionConfig {\n const mode = raw.mode ?? 'claim'\n if (mode !== 'claim' && mode !== 'scheduled') {\n throw new Error(\"execution.mode must be 'claim' or 'scheduled'\")\n }\n if (mode === 'claim') return { mode }\n const cron = (raw.cron ?? '').trim()\n const match = parseCron(cron)\n if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')\n const next = nextCronTime(match, now)\n if (next === null) throw new Error('execution.cron never matches within 4 years')\n return { mode, cron, nextRunAt: next }\n}\n\n/**\n * The effective prompt of a task: explicit prompt, or title+description.\n * @param task - the task.\n */\nexport function effectivePrompt(task: TaskRecord): string {\n if (task.prompt.length > 0) return task.prompt\n const head = task.title\n return task.description.length > 0 ? `${head}\\n\\n${task.description}` : head\n}\n\n/**\n * Whether the task is currently claimed by a session (running state).\n * @param task - the task.\n */\nexport function isClaimedBy(task: TaskRecord): string | undefined {\n return task.status === 'in_progress' && task.claimedBy !== undefined ? task.claimedBy : undefined\n}\n\n/**\n * Maintain the explicit claim fields around a status change: entering\n * in_progress under a session records the holder (an execution-start or an\n * agent claim); every move out of in_progress releases the claim (handoff,\n * give-back, cancel). A user-driven move into in_progress records no holder —\n * no session works on it yet.\n * @param task - the task being written (mutated in place).\n * @param to - the target status.\n * @param now - current epoch ms.\n * @param holder - the session id claiming the task, when applicable.\n */\nexport function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?: string): void {\n if (to !== 'in_progress') {\n delete task.claimedBy\n delete task.claimedAt\n } else if (holder !== undefined) {\n task.claimedBy = holder\n task.claimedAt = now\n }\n}\n\n/**\n * Validate and normalize a pinned model: `{ provider, model }`, both\n * non-empty trimmed strings.\n * @param raw - the raw input.\n * @returns the normalized model.\n * @throws when the shape or the fields are invalid.\n */\nexport function normalizeModel(raw: unknown): TaskModel {\n if (typeof raw !== 'object' || raw === null) {\n throw new Error('model must be { provider: string, model: string }')\n }\n const { provider, model } = raw as { provider?: unknown; model?: unknown }\n if (typeof provider !== 'string' || typeof model !== 'string') {\n throw new Error('model must be { provider: string, model: string }')\n }\n const p = provider.trim()\n const m = model.trim()\n if (p.length === 0 || m.length === 0) {\n throw new Error('model.provider and model.model must be non-empty strings')\n }\n return { provider: p, model: m }\n}\n\n/**\n * Compact list-projection of a task (token-friendly for `taskboard_list`).\n * @param task - the task.\n */\nexport type TaskSummary = {\n id: string\n title: string\n workspaceId: string\n urgency: Urgency\n status: TaskStatus\n blocked: boolean\n executionMode: ExecutionMode\n nextRunAt?: number\n model?: TaskModel\n version: number\n claimOwner?: string\n commentCount: number\n lastExecutionOutcome?: ExecutionRecord['outcome']\n trashed: boolean\n}\n\n/**\n * Build the compact summary of a task.\n * @param task - the task.\n */\nexport function summarize(task: TaskRecord): TaskSummary {\n const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined\n return {\n id: task.id,\n title: task.title,\n workspaceId: task.workspaceId,\n urgency: task.urgency,\n status: task.status,\n blocked: task.blocked,\n executionMode: task.execution.mode,\n nextRunAt: task.execution.nextRunAt,\n model: task.model,\n version: task.version,\n claimOwner: isClaimedBy(task),\n commentCount: task.comments.length,\n lastExecutionOutcome: last?.outcome,\n trashed: task.trashedAt !== undefined,\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.1.1",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/src/client/api.ts CHANGED
@@ -48,6 +48,8 @@ export interface TaskboardClient {
48
48
  remove(id: string, body: DeleteTaskBody): Promise<{ trashed?: boolean; purged?: boolean }>
49
49
  /** Trigger a manual run (fresh in-project session). */
50
50
  run(id: string): Promise<{ executionId: string; sessionId: string }>
51
+ /** Cancel the running execution (stops the agent session; task returns to todo). */
52
+ cancel(id: string): Promise<{ cancelled: true; executionId: string }>
51
53
  /** Subscribe to change frames; the disposer stops the stream. */
52
54
  stream(onChange: (event: ChangeEvent) => void, onGap: () => void): () => void
53
55
  }
@@ -64,6 +66,7 @@ export function createClient(): TaskboardClient {
64
66
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
65
67
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
66
68
  run: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, {}),
69
+ cancel: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/cancel`, {}),
67
70
  stream(onChange, onGap) {
68
71
  const es = new EventSource('/dsh-taskboard/events')
69
72
  let revision: number | undefined
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A lightweight alert modal — replaces native alert() with a themed overlay
3
+ * that matches the shell design tokens.
4
+ *
5
+ * @module dsh-taskboard/client/board/AlertModal
6
+ */
7
+ import { useState, useEffect } from 'react'
8
+
9
+ /** Show a non-blocking alert modal. Returns true when opened. */
10
+ export function useAlert(): { alert: (msg: string) => void; el: React.ReactNode } {
11
+ const [msg, setMsg] = useState<string | null>(null)
12
+ const show = (m: string) => setMsg(m)
13
+ const close = () => setMsg(null)
14
+ const el = msg !== null
15
+ ? <AlertModal message={msg} onClose={close} />
16
+ : null
17
+ return { alert: show, el }
18
+ }
19
+
20
+ function AlertModal({ message, onClose }: { message: string; onClose: () => void }) {
21
+ useEffect(() => {
22
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
23
+ window.addEventListener('keydown', handler)
24
+ return () => window.removeEventListener('keydown', handler)
25
+ }, [onClose])
26
+
27
+ return (
28
+ <div className="dsh-atb-alert-backdrop" onClick={onClose}>
29
+ <div className="dsh-atb-alert" onClick={e => e.stopPropagation()}>
30
+ <div className="dsh-atb-alert-icon">⛔</div>
31
+ <div className="dsh-atb-alert-msg">{message}</div>
32
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={onClose}>知道了</button>
33
+ </div>
34
+ </div>
35
+ )
36
+ }
@@ -4,13 +4,15 @@
4
4
  *
5
5
  * @module dsh-taskboard/client/board/TaskBoard
6
6
  */
7
- import { useSyncExternalStore } from 'react'
7
+ import { useEffect, useState, useSyncExternalStore } from 'react'
8
8
  import type { BoardController, ControllerState } from '../controller.ts'
9
9
  import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
10
- import { MAIN_STATUSES } from '../../shared/protocol.ts'
10
+ import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
11
+ import { PLUGIN_VERSION } from '../../shared/version.ts'
11
12
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
12
13
  import { TaskDetail } from './TaskDetail.tsx'
13
14
  import { TaskFormModal } from './TaskFormModal.tsx'
15
+ import { useAlert } from './AlertModal.tsx'
14
16
 
15
17
  /** Column labels. */
16
18
  const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
@@ -23,8 +25,6 @@ const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
23
25
  archived: '已归档',
24
26
  }
25
27
 
26
- /** The two columns between which cards may be dragged both ways. */
27
- const DRAGGABLE_STATUSES: ReadonlySet<TaskStatus> = new Set(['backlog', 'todo'])
28
28
 
29
29
  /** Urgency chip labels. */
30
30
  const URGENCY_LABELS: Readonly<Record<Urgency, string>> = {
@@ -41,11 +41,30 @@ export function fmtTime(ms: number | undefined): string {
41
41
  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
42
42
  }
43
43
 
44
- /** Apply the active filters to a task list. */
45
- function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRecord[] {
46
- return tasks.filter(t =>
44
+ /** A claim idle for longer than this is highlighted as stale (ms). */
45
+ export const STALE_CLAIM_MS = 30 * 60_000
46
+
47
+ /** Whether the task's claim is stale (in_progress, held, idle too long). */
48
+ export function isStaleClaim(task: TaskRecord, now: number): boolean {
49
+ return task.status === 'in_progress' && task.claimedAt !== undefined && now - task.claimedAt > STALE_CLAIM_MS
50
+ }
51
+
52
+ /** Urgency sort rank (urgent first). */
53
+ const URGENCY_RANK: Record<Urgency, number> = { urgent: 0, normal: 1, relaxed: 2 }
54
+
55
+ /** Apply the active filters + search + sort to a task list. */
56
+ export function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRecord[] {
57
+ const q = state.search.trim().toLowerCase()
58
+ const filtered = tasks.filter(t =>
47
59
  (state.filters.workspaceId === undefined || t.workspaceId === state.filters.workspaceId)
48
- && (state.filters.urgencies.length === 0 || state.filters.urgencies.includes(t.urgency)))
60
+ && (state.filters.urgencies.length === 0 || state.filters.urgencies.includes(t.urgency))
61
+ && (q.length === 0 || t.title.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)))
62
+ if (state.sortBy === 'default') return filtered
63
+ const sorted = [...filtered]
64
+ if (state.sortBy === 'updated') sorted.sort((a, b) => b.updatedAt - a.updatedAt)
65
+ else if (state.sortBy === 'created') sorted.sort((a, b) => b.createdAt - a.createdAt)
66
+ else if (state.sortBy === 'urgency') sorted.sort((a, b) => URGENCY_RANK[a.urgency] - URGENCY_RANK[b.urgency] || b.updatedAt - a.updatedAt)
67
+ return sorted
49
68
  }
50
69
 
51
70
  /**
@@ -57,15 +76,32 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
57
76
  cb => controller.subscribe(cb),
58
77
  () => controller.getSnapshot(),
59
78
  )
79
+ // Minute ticker: re-renders stale-claim highlights even without ledger changes.
80
+ const [now, setNow] = useState(() => Date.now())
81
+ useEffect(() => {
82
+ const timer = setInterval(() => setNow(Date.now()), 60_000)
83
+ return () => clearInterval(timer)
84
+ }, [])
60
85
  const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
61
86
  const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
87
+ const { alert: showAlert, el: alertEl } = useAlert()
62
88
 
63
89
  return (
64
90
  <div className="dsh-atb-board">
65
91
  <div className="dsh-atb-toolbar">
66
92
  <h2 className="dsh-atb-title">Agent 任务看板</h2>
67
93
  <span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
94
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
95
+ + 新建任务
96
+ </button>
68
97
  <div className="dsh-atb-spacer" />
98
+ <input
99
+ className="dsh-atb-input dsh-atb-search"
100
+ value={state.search}
101
+ placeholder="搜索标题 / ID…"
102
+ spellCheck={false}
103
+ onChange={e => controller.setSearch(e.target.value)}
104
+ />
69
105
  <select
70
106
  className="dsh-atb-select"
71
107
  value={state.filters.workspaceId ?? ''}
@@ -74,6 +110,17 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
74
110
  <option value="">全部项目</option>
75
111
  {state.workspaces.map(ws => <option key={ws.id} value={ws.id}>{ws.title || ws.path}</option>)}
76
112
  </select>
113
+ <select
114
+ className="dsh-atb-select"
115
+ value={state.sortBy}
116
+ title="列内排序"
117
+ onChange={e => controller.setSortBy(e.target.value as typeof state.sortBy)}
118
+ >
119
+ <option value="default">默认排序</option>
120
+ <option value="updated">最近更新</option>
121
+ <option value="urgency">按紧急度</option>
122
+ <option value="created">创建时间</option>
123
+ </select>
77
124
  {(['urgent', 'normal', 'relaxed'] as const).map(u => (
78
125
  <button
79
126
  key={u}
@@ -90,9 +137,16 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
90
137
  <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
91
138
  {state.secondaryOpen ? '返回看板' : '其它任务'}
92
139
  </button>
93
- <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
94
- + 新建任务
95
- </button>
140
+ <button type="button" className="dsh-atb-btn" title="下载完整台账备份(JSON)" onClick={() => controller.exportJson()}>⬇ JSON</button>
141
+ <button type="button" className="dsh-atb-btn" title="下载任务清单(CSV)" onClick={() => controller.exportCsv()}>⬇ CSV</button>
142
+ <a
143
+ className="dsh-atb-ver"
144
+ href="https://github.com/cloader/dsh-taskboard"
145
+ target="_blank"
146
+ rel="noopener noreferrer"
147
+ >
148
+ V{PLUGIN_VERSION}
149
+ </a>
96
150
  </div>
97
151
 
98
152
  {state.error !== undefined && <div className="dsh-atb-error">{state.error}</div>}
@@ -103,36 +157,34 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
103
157
  <div className="dsh-atb-columns">
104
158
  {MAIN_STATUSES.map(status => {
105
159
  const columnTasks = live.filter(t => t.status === status)
106
- const dropTarget = DRAGGABLE_STATUSES.has(status)
107
160
  return (
108
161
  <div
109
162
  className="dsh-atb-column"
110
163
  key={status}
111
- onDragOver={dropTarget
112
- ? (e) => {
113
- if (e.dataTransfer.types.includes(DRAG_TYPE)) {
114
- e.preventDefault()
115
- e.dataTransfer.dropEffect = 'move'
116
- e.currentTarget.dataset.dragover = 'true'
117
- }
118
- }
119
- : undefined}
120
- onDragLeave={dropTarget
121
- ? (e) => { delete e.currentTarget.dataset.dragover }
122
- : undefined}
123
- onDrop={dropTarget
124
- ? (e) => {
125
- e.preventDefault()
126
- delete e.currentTarget.dataset.dragover
127
- const id = e.dataTransfer.getData(DRAG_TYPE)
128
- if (id.length === 0) return
129
- const task = state.ledger.tasks.find(t => t.id === id)
130
- if (task === undefined || task.status === status) return
131
- void controller.move(id, task.version, status)
132
- }
133
- : undefined}
164
+ onDragOver={(e) => {
165
+ if (e.dataTransfer.types.includes(DRAG_TYPE)) {
166
+ e.preventDefault()
167
+ e.dataTransfer.dropEffect = 'move'
168
+ e.currentTarget.dataset.dragover = 'true'
169
+ }
170
+ }}
171
+ onDragLeave={(e) => { delete e.currentTarget.dataset.dragover }}
172
+ onDrop={(e) => {
173
+ e.preventDefault()
174
+ delete e.currentTarget.dataset.dragover
175
+ const id = e.dataTransfer.getData(DRAG_TYPE)
176
+ if (id.length === 0) return
177
+ const task = state.ledger.tasks.find(t => t.id === id)
178
+ if (task === undefined || task.status === status) return
179
+ if (!canTransition(task.status, status)) {
180
+ showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`)
181
+ return
182
+ }
183
+ void controller.move(id, task.version, status)
184
+ }}
134
185
  >
135
186
  <div className="dsh-atb-colhead">
187
+ <span className="dsh-atb-dot" data-status={status} />
136
188
  {COLUMN_LABELS[status]}
137
189
  <span className="dsh-atb-colcount">{columnTasks.length}</span>
138
190
  </div>
@@ -142,7 +194,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
142
194
  key={task.id}
143
195
  task={task}
144
196
  controller={controller}
145
- draggable={dropTarget}
197
+ draggable
198
+ now={now}
199
+ onAlert={showAlert}
146
200
  />
147
201
  ))}
148
202
  {columnTasks.length === 0 && <div className="dsh-atb-empty">无任务</div>}
@@ -155,7 +209,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
155
209
 
156
210
  {selected !== undefined && (
157
211
  <div className="dsh-atb-detailpanel">
158
- <TaskDetail task={selected} controller={controller} />
212
+ <TaskDetail task={selected} controller={controller} now={now} />
159
213
  </div>
160
214
  )}
161
215
 
@@ -165,20 +219,48 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
165
219
  task={state.editingId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.editingId)}
166
220
  />
167
221
  )}
222
+
223
+ {alertEl}
168
224
  </div>
169
225
  )
170
226
  }
171
227
 
172
- /** Secondary tab: canceled/archived/trashed rows. */
228
+ /** Secondary tab: tasks grouped into canceled / archived / trashed columns. */
173
229
  function SecondaryTab({ controller, tasks }: { controller: BoardController; tasks: TaskRecord[] }) {
174
- const rows = tasks.filter(t => t.status === 'canceled' || t.status === 'archived' || t.trashedAt !== undefined)
230
+ // Trashed takes precedence (a trashed task still carries its old status,
231
+ // but what matters to the user is the pending purge).
232
+ const trashed = tasks.filter(t => t.trashedAt !== undefined)
233
+ const archived = tasks.filter(t => t.trashedAt === undefined && t.status === 'archived')
234
+ const canceled = tasks.filter(t => t.trashedAt === undefined && t.status === 'canceled')
235
+ const groups = [
236
+ { label: '已取消', dot: 'canceled', rows: canceled },
237
+ { label: '已归档', dot: 'archived', rows: archived },
238
+ { label: '已删除', dot: 'trashed', rows: trashed },
239
+ ]
240
+ if (trashed.length + archived.length + canceled.length === 0) {
241
+ return (
242
+ <div className="dsh-atb-secondary">
243
+ <div className="dsh-atb-empty">无已取消 / 已归档 / 已删除任务</div>
244
+ </div>
245
+ )
246
+ }
175
247
  return (
176
- <div className="dsh-atb-secondary">
177
- {rows.length === 0 && <div className="dsh-atb-empty">无已取消 / 已归档 / 已删除任务</div>}
178
- {rows.map(task => (
179
- <TaskCard key={task.id} task={task} controller={controller} />
248
+ <div className="dsh-atb-columns">
249
+ {groups.map(group => (
250
+ <div className="dsh-atb-column" key={group.label}>
251
+ <div className="dsh-atb-colhead">
252
+ <span className="dsh-atb-dot" data-status={group.dot} />
253
+ {group.label}
254
+ <span className="dsh-atb-colcount">{group.rows.length}</span>
255
+ </div>
256
+ <div className="dsh-atb-cards">
257
+ {group.rows.map(task => (
258
+ <TaskCard key={task.id} task={task} controller={controller} />
259
+ ))}
260
+ {group.rows.length === 0 && <div className="dsh-atb-empty">无任务</div>}
261
+ </div>
262
+ </div>
180
263
  ))}
181
- {void controller}
182
264
  </div>
183
265
  )
184
266
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { BoardController } from '../controller.ts'
10
10
  import type { TaskRecord } from '../../shared/protocol.ts'
11
- import { fmtTime } from './TaskBoard.tsx'
11
+ import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
12
12
 
13
13
  const URGENCY_LABEL: Record<TaskRecord['urgency'], string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
14
14
  const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
@@ -20,10 +20,14 @@ export const DRAG_TYPE = 'application/x-dsh-atb-task'
20
20
  * The card view.
21
21
  * @param task - the task record.
22
22
  * @param controller - the controller.
23
- * @param draggable - enable dragging (backlog/todo columns only).
23
+ * @param draggable - enable dragging.
24
+ * @param now - current epoch ms (stale-claim highlight).
25
+ * @param onAlert - show an alert message (replaces native alert).
24
26
  */
25
- export function TaskCard({ task, controller, draggable = false }: { task: TaskRecord; controller: BoardController; draggable?: boolean }) {
27
+ export function TaskCard({ task, controller, draggable = false, now, onAlert }: { task: TaskRecord; controller: BoardController; draggable?: boolean; now?: number; onAlert?: (msg: string) => void }) {
26
28
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
29
+ const running = task.executions.find(ex => ex.outcome === 'running')
30
+ const stale = now !== undefined && isStaleClaim(task, now)
27
31
  return (
28
32
  <button
29
33
  type="button"
@@ -31,6 +35,14 @@ export function TaskCard({ task, controller, draggable = false }: { task: TaskRe
31
35
  data-urgency={task.urgency}
32
36
  draggable={draggable}
33
37
  onDragStart={(e) => {
38
+ // Block drag if a session is still executing this task
39
+ if (running !== undefined) {
40
+ e.preventDefault()
41
+ const msg = `该任务正在由【${task.title}】会话执行,不能拖动`
42
+ if (onAlert !== undefined) onAlert(msg)
43
+ else alert(msg)
44
+ return
45
+ }
34
46
  e.dataTransfer.setData(DRAG_TYPE, task.id)
35
47
  e.dataTransfer.effectAllowed = 'move'
36
48
  e.currentTarget.dataset.dragging = 'true'
@@ -42,6 +54,7 @@ export function TaskCard({ task, controller, draggable = false }: { task: TaskRe
42
54
  <div className="dsh-atb-card-meta">
43
55
  <span className="dsh-atb-badge">{URGENCY_LABEL[task.urgency]}</span>
44
56
  {task.blocked && <span className="dsh-atb-badge" data-kind="blocked">受阻</span>}
57
+ {stale && <span className="dsh-atb-badge" data-kind="stale">⏱ 认领超时</span>}
45
58
  {task.execution.mode === 'scheduled' && (
46
59
  <span className="dsh-atb-badge" data-kind="scheduled">⏰ {fmtTime(task.execution.nextRunAt)}</span>
47
60
  )}