dsh-task-ledger 0.0.45 → 0.0.47

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.
Files changed (36) hide show
  1. package/dist/client.js +130 -46
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/board.d.ts +13 -108
  4. package/dist/host/board.js +19 -285
  5. package/dist/host/errors.d.ts +42 -0
  6. package/dist/host/errors.js +55 -0
  7. package/dist/host/repositories/cache.d.ts +27 -0
  8. package/dist/host/repositories/cache.js +57 -0
  9. package/dist/host/repositories/identity.repository.d.ts +9 -0
  10. package/dist/host/repositories/identity.repository.js +20 -0
  11. package/dist/host/{ledger-read.d.ts → repositories/ledger.repository.d.ts} +10 -4
  12. package/dist/host/repositories/ledger.repository.js +85 -0
  13. package/dist/host/repositories/records.d.ts +5 -0
  14. package/dist/host/repositories/records.js +48 -0
  15. package/dist/host/{script.js → repositories/runner.js} +3 -2
  16. package/dist/host/routes.d.ts +17 -0
  17. package/dist/host/routes.js +215 -64
  18. package/dist/host/services/dates.d.ts +37 -0
  19. package/dist/host/services/dates.js +71 -0
  20. package/dist/host/services/history.service.d.ts +24 -0
  21. package/dist/host/services/history.service.js +61 -0
  22. package/dist/host/services/home.service.d.ts +13 -0
  23. package/dist/host/services/home.service.js +50 -0
  24. package/dist/host/services/session.service.d.ts +110 -0
  25. package/dist/host/services/session.service.js +145 -0
  26. package/dist/host/services/trend.service.d.ts +26 -0
  27. package/dist/host/services/trend.service.js +134 -0
  28. package/dist/host/{skill-registration.d.ts → skill/registration.d.ts} +2 -0
  29. package/dist/host/{skill-registration.js → skill/registration.js} +5 -2
  30. package/dist/host/types.d.ts +94 -0
  31. package/dist/host/workspace.d.ts +8 -0
  32. package/dist/host/workspace.js +6 -9
  33. package/dist/index.js +31 -2
  34. package/package.json +4 -3
  35. package/dist/host/ledger-read.js +0 -64
  36. /package/dist/host/{script.d.ts → repositories/runner.d.ts} +0 -0
@@ -0,0 +1,20 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
4
+ * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
5
+ * 该工作区的「我的」统计按 0 计(不谎报)。
6
+ *
7
+ * 全 host 唯一 spawn git 的地方(python3 的唯一出口在 ./runner.ts)。
8
+ */
9
+ export function getCurrentIdentity(workspace) {
10
+ return new Promise((resolve) => {
11
+ const child = spawn('git', ['config', 'user.name'], { cwd: workspace.path, stdio: ['ignore', 'pipe', 'ignore'] });
12
+ let stdout = '';
13
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
14
+ child.on('error', () => { resolve(null); });
15
+ child.on('close', (code) => {
16
+ const name = stdout.trim();
17
+ resolve(code === 0 && name !== '' ? name : null);
18
+ });
19
+ });
20
+ }
@@ -1,15 +1,21 @@
1
- import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
1
+ import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from '../types.js';
2
2
  /**
3
3
  * LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
4
4
  * 让脚本走自己的默认——插件不替脚本决定默认值)。
5
5
  */
6
6
  export declare function filterArgs(filter: LedgerFilter): string[];
7
7
  /**
8
- * 在办任务(包 `list --json`;结果打上 workspace)。
9
- * 脚本失败抛错——路由层转 500 + 人话,不静默返空(空 = 撒谎)。
8
+ * 台账工作区探测:`<path>/ledger/progress.json` 在才算(未初始化 = 后端不可见,
9
+ * 2026-10-17 拍板)。全 host 对台账目录的 IO 只在本文件——别处再写 existsSync 即为分层倒退。
10
+ */
11
+ export declare function hasLedger(workspacePath: string): boolean;
12
+ /**
13
+ * 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
14
+ * 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
15
+ * 不静默返空(空 = 撒谎)。**过 1.5s 读缓存**(同屏多端点会反复读同一份在办)。
10
16
  */
11
17
  export declare function readTasks(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<TaskRow[]>;
12
- /** 已结案记录(包 `history --json`;结果打上 workspace)。 */
18
+ /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。同样过读缓存。 */
13
19
  export declare function readHistory(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<HistoryRecord[]>;
14
20
  /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
15
21
  export declare function getTask(workspace: LedgerWorkspace, taskId: string): Promise<TaskRow | null>;
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { runLedgerScript } from './runner.js';
5
+ import { parseHistoryRecords, parseTaskList } from './records.js';
6
+ import { cacheKey, cached } from './cache.js';
7
+ /** 台账目录名(各工作区根下的这一个目录;布局知识只在本文件出现一次)。 */
8
+ const LEDGER_DIR = 'ledger';
9
+ /**
10
+ * LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
11
+ * 让脚本走自己的默认——插件不替脚本决定默认值)。
12
+ */
13
+ export function filterArgs(filter) {
14
+ const args = [];
15
+ if (filter.status !== undefined)
16
+ args.push('--status', filter.status);
17
+ if (filter.owner !== undefined)
18
+ args.push('--owner', filter.owner);
19
+ if (filter.category !== undefined)
20
+ args.push('--category', filter.category);
21
+ if (filter.tag !== undefined)
22
+ args.push('--tag', filter.tag);
23
+ if (filter.since !== undefined)
24
+ args.push('--since', filter.since);
25
+ if (filter.until !== undefined)
26
+ args.push('--until', filter.until);
27
+ if (filter.limit !== undefined)
28
+ args.push('--limit', String(filter.limit));
29
+ if (filter.months !== undefined)
30
+ args.push('--months', String(filter.months));
31
+ return args;
32
+ }
33
+ /**
34
+ * 台账工作区探测:`<path>/ledger/progress.json` 在才算(未初始化 = 后端不可见,
35
+ * 2026-10-17 拍板)。全 host 对台账目录的 IO 只在本文件——别处再写 existsSync 即为分层倒退。
36
+ */
37
+ export function hasLedger(workspacePath) {
38
+ return existsSync(path.join(workspacePath, LEDGER_DIR, 'progress.json'));
39
+ }
40
+ /**
41
+ * 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
42
+ * 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
43
+ * 不静默返空(空 = 撒谎)。**过 1.5s 读缓存**(同屏多端点会反复读同一份在办)。
44
+ */
45
+ export async function readTasks(workspace, filter = {}) {
46
+ return await cached(cacheKey(workspace.id, 'list', argsKey(filter)), async () => {
47
+ const result = await runLedgerScript('list', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
48
+ if (!result.ok)
49
+ throw new Error(`${workspace.title}: ${result.error}`);
50
+ return parseTaskList(result.data, workspace);
51
+ });
52
+ }
53
+ /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。同样过读缓存。 */
54
+ export async function readHistory(workspace, filter = {}) {
55
+ return await cached(cacheKey(workspace.id, 'history', argsKey(filter)), async () => {
56
+ const result = await runLedgerScript('history', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
57
+ if (!result.ok)
58
+ throw new Error(`${workspace.title}: ${result.error}`);
59
+ return parseHistoryRecords(result.data, workspace);
60
+ });
61
+ }
62
+ /** 缓存键的 filter 段:脚本参数原样序列化(分隔符取不可见字符,值里带空格也不会串)。 */
63
+ function argsKey(filter) {
64
+ return filterArgs(filter).join('\u0001');
65
+ }
66
+ /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
67
+ export async function getTask(workspace, taskId) {
68
+ const rows = await readTasks(workspace);
69
+ return rows.find((row) => row.id === taskId) ?? null;
70
+ }
71
+ /**
72
+ * 过程文档正文(`task.worklog` 相对 `ledger/` 的路径)。
73
+ * 读不到回 null(文件被清 / 路径坏都不炸——由调用方展示「过程文档缺失」)。
74
+ */
75
+ export async function readWorklog(workspace, task) {
76
+ const relative = task.worklog;
77
+ if (relative === undefined || relative === null || relative === '')
78
+ return null;
79
+ try {
80
+ return await readFile(path.join(workspace.path, LEDGER_DIR, relative), 'utf-8');
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
@@ -0,0 +1,5 @@
1
+ import type { HistoryRecord, LedgerWorkspace, TaskRow } from '../types.js';
2
+ /** `list --json`(任务号为键的对象)→ TaskRow[](打上 id 与 workspace)。 */
3
+ export declare function parseTaskList(raw: unknown, workspace: LedgerWorkspace): TaskRow[];
4
+ /** `history --json`(`{count, filters, records}`)→ HistoryRecord[](打上 workspace)。 */
5
+ export declare function parseHistoryRecords(raw: unknown, workspace: LedgerWorkspace): HistoryRecord[];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 入口校验(纯函数):skill 脚本 JSON → 领域对象。
3
+ *
4
+ * 口径:**容器级严格、元素级宽松**。
5
+ * - 容器:`list --json` 顶层必须是 plain object(任务号为键);`history --json` 的 `records`
6
+ * 必须存在且为数组(现状 `records ?? []` 的容忍在脚本回归时会静默产出空历史 = 撒谎);
7
+ * - 元素:是 object 就收,字段缺失一律放行(TaskRecord 全字段可选是脚本既有契约,
8
+ * 旧 8 列 history 不因此被误杀)。
9
+ * 失败抛 {@link LedgerDataError}——由 service 层捕获进 degraded,不静默。
10
+ */
11
+ import { LedgerDataError } from '../errors.js';
12
+ function isPlainObject(value) {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+ /** `list --json`(任务号为键的对象)→ TaskRow[](打上 id 与 workspace)。 */
16
+ export function parseTaskList(raw, workspace) {
17
+ if (!isPlainObject(raw)) {
18
+ throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 list 输出不是「任务号为键的对象」,拒绝当成空列表`);
19
+ }
20
+ const rows = [];
21
+ for (const [id, value] of Object.entries(raw)) {
22
+ if (!isPlainObject(value)) {
23
+ throw new LedgerDataError(workspace.id, `${workspace.title}: 任务 ${id} 不是对象,台账格式可能已变`);
24
+ }
25
+ if (id === '') {
26
+ throw new LedgerDataError(workspace.id, `${workspace.title}: 台账存在空任务号,格式可能已变`);
27
+ }
28
+ rows.push({ ...value, id, workspace });
29
+ }
30
+ return rows;
31
+ }
32
+ /** `history --json`(`{count, filters, records}`)→ HistoryRecord[](打上 workspace)。 */
33
+ export function parseHistoryRecords(raw, workspace) {
34
+ if (!isPlainObject(raw)) {
35
+ throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 history 输出不是对象,拒绝当成空历史`);
36
+ }
37
+ const records = raw.records;
38
+ if (!Array.isArray(records)) {
39
+ throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 history 输出缺 records 数组,格式可能已变`);
40
+ }
41
+ const rows = [];
42
+ for (const record of records) {
43
+ if (!isPlainObject(record))
44
+ continue; // 元素级宽松:坏行跳过,不整板炸
45
+ rows.push({ ...record, workspace });
46
+ }
47
+ return rows;
48
+ }
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  /**
4
- * 内嵌 skill 脚本的默认路径:dist/host/script.js → 包根 skill/scripts/progress.py。
4
+ * 内嵌 skill 脚本的默认路径:dist/host/repositories/runner.js → 包根 skill/scripts/progress.py
5
+ * (比原 host/script.js 深一层 ⇒ 相对路径多一级 `../`)。
5
6
  * 构建时 scripts/copy-skill.mjs 把 packages/skill 拷到 packages/plugin/skill/。
6
7
  */
7
- const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../skill/scripts/progress.py', import.meta.url));
8
+ const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../../skill/scripts/progress.py', import.meta.url));
8
9
  /** 单次脚本调用超时上限(ms)。实测单仓 5 万行全量 1.2s,10s 是宽松上限。 */
9
10
  const DEFAULT_TIMEOUT_MS = 10_000;
10
11
  /**
@@ -5,6 +5,7 @@
5
5
  * 见 external/host-webserver.md。
6
6
  */
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { type AgentsLike } from './services/session.service.js';
8
9
  import { type RegistryWorkspace } from './workspace.js';
9
10
  /** 宿主 webServer.register 的最小面(WebRoute 形状见 external/host-webserver.md)。 */
10
11
  export interface WebServerLike {
@@ -22,3 +23,19 @@ export interface RouteDeps {
22
23
  readonly registry: WorkspaceRegistryLike;
23
24
  }
24
25
  export declare function registerRoutes(webServer: WebServerLike, deps: RouteDeps): void;
26
+ export interface SessionRouteDeps {
27
+ readonly registry: WorkspaceRegistryLike;
28
+ /** 起会话的服务面;宿主缺就回 503 人话(端点照常注册,前端不用按版本分支)。 */
29
+ readonly agents: AgentsLike | undefined;
30
+ /** 惰性服务读取:`agentPresets` / `agentDefaultModel` 可能整条不存在,或被 ctx 的 inject 门挡住。 */
31
+ readonly getService: (name: string) => unknown;
32
+ }
33
+ /**
34
+ * 注册「结束任务」端点:`POST /close`。
35
+ *
36
+ * 为什么只有这一条走会话(2026-10-18 用户拍板):认领 / 释放没有判断量,host 直写即可;
37
+ * **结束**要带用户留言、要模型读过程文档后写真摘要 —— 只能交给会话里的 Agent 做。
38
+ *
39
+ * 请求体:`{ ws, id, message?, provider?, model? }`;响应:`{ sessionId, warning? }`。
40
+ */
41
+ export declare function registerSessionRoutes(webServer: WebServerLike, deps: SessionRouteDeps): void;
@@ -1,5 +1,9 @@
1
1
  import { getHomeAggregate, getFilterOptions, queryClosed } from './board.js';
2
- import { getTask, readTasks, readWorklog } from './ledger-read.js';
2
+ import { getSnapshot } from './services/home.service.js';
3
+ import { getTrend } from './services/trend.service.js';
4
+ import { getTask, readTasks, readWorklog } from './repositories/ledger.repository.js';
5
+ import { invalidate } from './repositories/cache.js';
6
+ import { HandoffError, startCloseSession, } from './services/session.service.js';
3
7
  import { resolveWorkspaces } from './workspace.js';
4
8
  /** API 前缀(唯一真源就在这一处)。 */
5
9
  const API_PREFIX = '/api/task-ledger';
@@ -47,93 +51,240 @@ function filterFromQuery(url) {
47
51
  filter.months = months;
48
52
  return filter;
49
53
  }
50
- /** 每请求重新解析工作区(registry 可能刚变;existsSync × N 是微秒级,不缓存)。 */
54
+ /** 每请求重新解析工作区(registry 可能刚变;探测走 hasLedger(),微秒级,不缓存)。 */
51
55
  function workspacesOf(deps) {
52
56
  return resolveWorkspaces(deps.registry.list());
53
57
  }
54
58
  export function registerRoutes(webServer, deps) {
55
- webServer.register({
56
- kind: 'exact',
57
- path: `${API_PREFIX}/workspaces`,
58
- handler: (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); },
59
- });
59
+ /**
60
+ * 注册一个**读**端点:统一先看 `?fresh=1`(手动刷新 = 清缓存再算,别让用户按了刷新还看旧数)。
61
+ * 缓存 TTL 只有 1.5s,正常浏览照样吃缓存;写端点将来自己走写路径 + `invalidate()`,不走这里。
62
+ */
63
+ const registerRead = (path, handler) => {
64
+ webServer.register({
65
+ kind: 'exact',
66
+ path: `${API_PREFIX}${path}`,
67
+ handler: async (req, res) => {
68
+ if (new URL(req.url ?? '/', 'http://localhost').searchParams.get('fresh') === '1')
69
+ invalidate();
70
+ await handler(req, res);
71
+ },
72
+ });
73
+ };
74
+ registerRead('/workspaces', (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); });
60
75
  // 首页一次拿全:在办快照 + 近 8 周趋势 + 四个自然周期计数(host 每个工作区只读一次)。
61
- webServer.register({
62
- kind: 'exact',
63
- path: `${API_PREFIX}/board`,
64
- handler: async (_req, res) => {
65
- try {
66
- const home = await getHomeAggregate(workspacesOf(deps));
67
- sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend });
76
+ registerRead('/board', async (_req, res) => {
77
+ try {
78
+ const home = await getHomeAggregate(workspacesOf(deps));
79
+ sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend, degraded: home.degraded });
80
+ }
81
+ catch (error) {
82
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
83
+ }
84
+ });
85
+ // ── 拆端点(2026-10-18 步骤 4a:纯加法,旧 /board 全量原样保留)──
86
+ // 快路径:首屏。只读 progress.json + git 身份,**不碰 history** ⇒ 耗时与历史总量解耦
87
+ // (20 万条量级实测 527ms vs 合并端点 4.4s)。
88
+ registerRead('/board/snapshot', async (_req, res) => {
89
+ try {
90
+ const result = await getSnapshot(workspacesOf(deps));
91
+ sendJson(res, 200, { ...result.snapshot, degraded: result.degraded });
92
+ }
93
+ catch (error) {
94
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
95
+ }
96
+ });
97
+ // 慢路径:趋势(近 8 周 + 四个自然周期)。读 history ⇒ 异步/按需拉,首屏不等它。
98
+ registerRead('/board/trend', async (_req, res) => {
99
+ try {
100
+ const result = await getTrend(workspacesOf(deps));
101
+ sendJson(res, 200, { closed: result.closed, trend: result.trend, degraded: result.degraded });
102
+ }
103
+ catch (error) {
104
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
105
+ }
106
+ });
107
+ registerRead('/tasks', async (req, res) => {
108
+ try {
109
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
110
+ const perWorkspace = await Promise.all(workspacesOf(deps).map((workspace) => readTasks(workspace, filter)));
111
+ sendJson(res, 200, { tasks: perWorkspace.flat() });
112
+ }
113
+ catch (error) {
114
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ });
117
+ // 任务详情 + 过程文档正文(query 传 ws/id,不做路径段解析——任务号含中文和连字符)。
118
+ registerRead('/task', async (req, res) => {
119
+ try {
120
+ const url = new URL(req.url ?? '/', 'http://localhost');
121
+ const workspace = workspacesOf(deps).find((item) => item.id === url.searchParams.get('ws'));
122
+ const taskId = url.searchParams.get('id');
123
+ if (workspace === undefined || taskId === null) {
124
+ sendJson(res, 400, { error: '缺少 ws / id 参数' });
125
+ return;
68
126
  }
69
- catch (error) {
70
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
127
+ const task = await getTask(workspace, taskId);
128
+ if (task === null) {
129
+ sendJson(res, 404, { error: '任务不存在' });
130
+ return;
71
131
  }
72
- },
132
+ sendJson(res, 200, { task, worklog: await readWorklog(workspace, task) });
133
+ }
134
+ catch (error) {
135
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
136
+ }
137
+ });
138
+ registerRead('/history', async (req, res) => {
139
+ try {
140
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
141
+ const result = await queryClosed(workspacesOf(deps), filter);
142
+ sendJson(res, 200, { records: result.records, degraded: result.degraded });
143
+ }
144
+ catch (error) {
145
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
146
+ }
73
147
  });
148
+ registerRead('/filters', async (_req, res) => {
149
+ try {
150
+ const result = await getFilterOptions(workspacesOf(deps));
151
+ sendJson(res, 200, { ...result.options, degraded: result.degraded });
152
+ }
153
+ catch (error) {
154
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
155
+ }
156
+ });
157
+ }
158
+ // ───────────────────────── 会话交接(按钮侧 · 写类端点) ─────────────────────────
159
+ /** 写端点请求体上限:只收一小段用户留言,别让人往这塞文件。 */
160
+ const BODY_LIMIT = 64 * 1024;
161
+ /** 读请求体(宿主 handler 收原始 req,没有 body parser;超上限直接拒,不缓冲垃圾)。 */
162
+ async function readJsonBody(req) {
163
+ const chunks = [];
164
+ let size = 0;
165
+ for await (const chunk of req) {
166
+ const buffer = chunk;
167
+ size += buffer.length;
168
+ if (size > BODY_LIMIT)
169
+ throw new Error('body-too-large');
170
+ chunks.push(buffer);
171
+ }
172
+ const text = Buffer.concat(chunks).toString('utf8');
173
+ if (text.trim() === '')
174
+ return {};
175
+ return JSON.parse(text);
176
+ }
177
+ /**
178
+ * 同源守卫(architecture 不变量 6 的落地):浏览器 fetch 必带与 Host 同源的 `Origin`;
179
+ * 没有 `Origin` 的(本机脚本 / Desktop IPC)只放行回环地址。
180
+ * 读端点保持现状(只读无副作用);**会起会话的写端点必须过这道**。
181
+ */
182
+ function isTrustedRequest(req) {
183
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
184
+ const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
185
+ if (origin === undefined) {
186
+ const address = req.socket.remoteAddress;
187
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
188
+ }
189
+ if (host === undefined)
190
+ return false;
191
+ try {
192
+ return new URL(origin).host === host;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ /**
199
+ * 注册「结束任务」端点:`POST /close`。
200
+ *
201
+ * 为什么只有这一条走会话(2026-10-18 用户拍板):认领 / 释放没有判断量,host 直写即可;
202
+ * **结束**要带用户留言、要模型读过程文档后写真摘要 —— 只能交给会话里的 Agent 做。
203
+ *
204
+ * 请求体:`{ ws, id, message?, provider?, model? }`;响应:`{ sessionId, warning? }`。
205
+ */
206
+ export function registerSessionRoutes(webServer, deps) {
74
207
  webServer.register({
75
208
  kind: 'exact',
76
- path: `${API_PREFIX}/tasks`,
209
+ path: `${API_PREFIX}/close`,
77
210
  handler: async (req, res) => {
78
- try {
79
- const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
80
- const perWorkspace = await Promise.all(workspacesOf(deps).map((workspace) => readTasks(workspace, filter)));
81
- sendJson(res, 200, { tasks: perWorkspace.flat() });
211
+ if (req.method !== 'POST') {
212
+ sendJson(res, 405, { error: '「结束任务」只支持 POST' });
213
+ return;
82
214
  }
83
- catch (error) {
84
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
215
+ if (!isTrustedRequest(req)) {
216
+ sendJson(res, 403, { error: '只接受同源请求' });
217
+ return;
85
218
  }
86
- },
87
- });
88
- // 任务详情 + 过程文档正文(query 传 ws/id,不做路径段解析——任务号含中文和连字符)。
89
- webServer.register({
90
- kind: 'exact',
91
- path: `${API_PREFIX}/task`,
92
- handler: async (req, res) => {
219
+ let body;
93
220
  try {
94
- const url = new URL(req.url ?? '/', 'http://localhost');
95
- const workspace = workspacesOf(deps).find((item) => item.id === url.searchParams.get('ws'));
96
- const taskId = url.searchParams.get('id');
97
- if (workspace === undefined || taskId === null) {
98
- sendJson(res, 400, { error: '缺少 ws / id 参数' });
99
- return;
100
- }
101
- const task = await getTask(workspace, taskId);
102
- if (task === null) {
103
- sendJson(res, 404, { error: '任务不存在' });
104
- return;
105
- }
106
- sendJson(res, 200, { task, worklog: await readWorklog(workspace, task) });
221
+ body = await readJsonBody(req);
107
222
  }
108
223
  catch (error) {
109
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
224
+ const tooLarge = error instanceof Error && error.message === 'body-too-large';
225
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? '请求体过大' : '请求体不是合法 JSON' });
226
+ return;
110
227
  }
111
- },
112
- });
113
- webServer.register({
114
- kind: 'exact',
115
- path: `${API_PREFIX}/history`,
116
- handler: async (req, res) => {
117
- try {
118
- const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
119
- sendJson(res, 200, { records: await queryClosed(workspacesOf(deps), filter) });
228
+ const fields = typeof body === 'object' && body !== null ? body : {};
229
+ const str = (key) => (typeof fields[key] === 'string' ? fields[key] : '');
230
+ const workspaceId = str('ws');
231
+ const taskId = str('id');
232
+ if (workspaceId === '' || taskId === '') {
233
+ sendJson(res, 400, { error: '缺少 ws / id 参数' });
234
+ return;
120
235
  }
121
- catch (error) {
122
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
236
+ const provider = str('provider');
237
+ const model = str('model');
238
+ if ((provider === '') !== (model === '')) {
239
+ sendJson(res, 400, { error: 'provider 与 model 必须成对给出' });
240
+ return;
241
+ }
242
+ const workspace = workspacesOf(deps).find((item) => item.id === workspaceId);
243
+ if (workspace === undefined) {
244
+ sendJson(res, 404, { error: '工作区不存在或不含台账' });
245
+ return;
123
246
  }
124
- },
125
- });
126
- webServer.register({
127
- kind: 'exact',
128
- path: `${API_PREFIX}/filters`,
129
- handler: async (_req, res) => {
130
247
  try {
131
- sendJson(res, 200, await getFilterOptions(workspacesOf(deps)));
248
+ const result = await startCloseSession({
249
+ agents: deps.agents,
250
+ presets: deps.getService('agentPresets'),
251
+ defaultModel: deps.getService('agentDefaultModel'),
252
+ }, {
253
+ workspace,
254
+ taskId,
255
+ message: str('message'),
256
+ model: provider === '' ? undefined : { provider, model },
257
+ });
258
+ const warning = await attachSession(deps, workspaceId, result.sessionId) ?? result.warning;
259
+ sendJson(res, 200, warning === undefined ? { sessionId: result.sessionId } : { sessionId: result.sessionId, warning });
132
260
  }
133
261
  catch (error) {
262
+ if (error instanceof HandoffError) {
263
+ const status = error.code === 'task-missing'
264
+ ? 404
265
+ : error.code === 'no-agents' || error.code === 'no-preset' || error.code === 'no-model' ? 503 : 500;
266
+ sendJson(res, status, { error: error.message, code: error.code });
267
+ return;
268
+ }
134
269
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
135
270
  }
136
271
  },
137
272
  });
138
- // POST /sessions(按钮会话交接)归「任务按钮与会话交接」任务——写入路径 A/B 拍板后再接。
273
+ }
274
+ /**
275
+ * 会话归组(best effort):登记进工作区 `sessionIds`,否则侧栏里找不到这个会话。
276
+ * 缺失 / 失败都不算致命——会话本身已经建好、跳转可用,只如实回一条 warning。
277
+ */
278
+ async function attachSession(deps, workspaceId, sessionId) {
279
+ const entity = deps.registry.list().find((item) => item.id === workspaceId);
280
+ if (entity === undefined || typeof entity.attachSession !== 'function') {
281
+ return '会话已建,但宿主该工作区没有 attachSession 面:会话不会出现在工作区列表里';
282
+ }
283
+ try {
284
+ await entity.attachSession(sessionId);
285
+ return undefined;
286
+ }
287
+ catch (error) {
288
+ return `会话已建,但没能挂进工作区列表:${error instanceof Error ? error.message : String(error)}`;
289
+ }
139
290
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 日期口径(纯函数层,零 IO):本机时区、周一为一周之始(与 skill 脚本同口径)。
3
+ * 所有函数显式收 `now`——不自己读时钟,便于单测与跨时区复现。
4
+ */
5
+ /** Date → `YYYY-MM-DD`。 */
6
+ export declare function ymd(date: Date): string;
7
+ /** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
8
+ export declare function dayOf(stamp: string | null | undefined): string | null;
9
+ /** 某日期所在周的周一(00:00)。 */
10
+ export declare function mondayOf(day: string): Date;
11
+ /**
12
+ * 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
13
+ * ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
14
+ * 日子也画进区间,tooltip 里的截止日撒谎。
15
+ */
16
+ export declare function weekWindow(offset: number, now: Date): {
17
+ from: string;
18
+ to: string;
19
+ };
20
+ /** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
21
+ * 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
22
+ export declare function monthWindow(offset: number, now: Date): {
23
+ from: string;
24
+ to: string;
25
+ };
26
+ /** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
27
+ export declare function inWindow(day: string | null, window: {
28
+ from: string;
29
+ to: string;
30
+ }): boolean;
31
+ /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
32
+ export declare function daysSince(stamp: string | null | undefined, now: Date): number | null;
33
+ /** 近 N 周的桶骨架(`{week, end}`,历史周 end=周日、当前周 end=今天)。 */
34
+ export declare function buildWeeks(count: number, now: Date): readonly {
35
+ week: string;
36
+ end: string;
37
+ }[];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 日期口径(纯函数层,零 IO):本机时区、周一为一周之始(与 skill 脚本同口径)。
3
+ * 所有函数显式收 `now`——不自己读时钟,便于单测与跨时区复现。
4
+ */
5
+ function pad(value) {
6
+ return String(value).padStart(2, '0');
7
+ }
8
+ /** Date → `YYYY-MM-DD`。 */
9
+ export function ymd(date) {
10
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
11
+ }
12
+ /** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
13
+ export function dayOf(stamp) {
14
+ if (stamp === null || stamp === undefined || stamp === '')
15
+ return null;
16
+ const day = String(stamp).slice(0, 10);
17
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
18
+ }
19
+ /** 某日期所在周的周一(00:00)。 */
20
+ export function mondayOf(day) {
21
+ const date = new Date(`${day}T00:00:00`);
22
+ date.setDate(date.getDate() - ((date.getDay() + 6) % 7));
23
+ return date;
24
+ }
25
+ /**
26
+ * 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
27
+ * ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
28
+ * 日子也画进区间,tooltip 里的截止日撒谎。
29
+ */
30
+ export function weekWindow(offset, now) {
31
+ const monday = mondayOf(ymd(now));
32
+ monday.setDate(monday.getDate() + offset * 7);
33
+ const sunday = new Date(monday);
34
+ sunday.setDate(sunday.getDate() + 6);
35
+ const last = offset === 0 ? now : sunday;
36
+ return { from: ymd(monday), to: ymd(last < sunday ? last : sunday) };
37
+ }
38
+ /** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
39
+ * 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
40
+ export function monthWindow(offset, now) {
41
+ const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
42
+ const last = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0);
43
+ const end = offset === 0 ? now : last;
44
+ return { from: ymd(first), to: ymd(end < last ? end : last) };
45
+ }
46
+ /** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
47
+ export function inWindow(day, window) {
48
+ return day !== null && day >= window.from && day <= window.to;
49
+ }
50
+ /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
51
+ export function daysSince(stamp, now) {
52
+ if (stamp === null || stamp === undefined || stamp === '')
53
+ return null;
54
+ const parsed = new Date(String(stamp).replace(' ', 'T'));
55
+ if (Number.isNaN(parsed.getTime()))
56
+ return null;
57
+ const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
58
+ return days > 0 ? days : 0;
59
+ }
60
+ /** 近 N 周的桶骨架(`{week, end}`,历史周 end=周日、当前周 end=今天)。 */
61
+ export function buildWeeks(count, now) {
62
+ const weeks = [];
63
+ for (let offset = count - 1; offset >= 0; offset -= 1)
64
+ weeks.push(weekWindow(-offset, now).from);
65
+ return weeks.map((week, i) => {
66
+ const monday = new Date(`${week}T00:00:00`);
67
+ const sunday = new Date(monday);
68
+ sunday.setDate(sunday.getDate() + 6);
69
+ return { week, end: i === weeks.length - 1 ? ymd(now) : ymd(sunday) };
70
+ });
71
+ }