@xirang-ai/dsh-teamflow 0.1.0 → 0.1.2
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/README.en.md +18 -14
- package/README.md +18 -14
- package/RELEASE.md +9 -9
- package/cordis.patch.yml +2 -0
- package/lib/client.js +494 -153
- package/lib/index.d.ts +7 -1
- package/lib/index.js +10 -4
- package/lib/remote/contract.d.ts +34 -1
- package/lib/remote/contract.js +16 -0
- package/lib/remote/service.d.ts +15 -2
- package/lib/remote/service.js +49 -6
- package/lib/requirement-discussion.d.ts +23 -0
- package/lib/requirement-discussion.js +26 -0
- package/lib/store/db.d.ts +8 -3
- package/lib/store/db.js +34 -2
- package/lib/store/docs.d.ts +9 -11
- package/lib/store/docs.js +25 -52
- package/lib/store/model.d.ts +23 -0
- package/lib/store/model.js +4 -0
- package/lib/store/paths.d.ts +20 -0
- package/lib/store/paths.js +54 -1
- package/lib/store/schema.js +71 -14
- package/lib/store/workflow.d.ts +50 -25
- package/lib/store/workflow.js +210 -97
- package/lib/tools/get-bug.js +1 -1
- package/lib/tools/get-design-doc.js +1 -1
- package/lib/tools/get-requirement.js +1 -1
- package/lib/tools/get-story.js +1 -1
- package/lib/tools/index.js +7 -0
- package/lib/tools/link-session.d.ts +3 -0
- package/lib/tools/link-session.js +87 -0
- package/locale/en.json +5 -1
- package/locale/zh.json +5 -1
- package/package.json +4 -4
package/lib/store/docs.js
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 文档落盘:正文不进 SQLite
|
|
2
|
+
* 文档落盘:正文不进 SQLite,只记文档根内的相对路径。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 目录结构(相对文档根,默认 `$DSH_HOME/xirang-ai/dsh-teamflow/docs`):
|
|
5
5
|
*
|
|
6
|
-
* .
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* bugs/<bug_id>/bug.md 缺陷正文(描述、复现、期望、实际、环境)
|
|
6
|
+
* requirements/<req_id>/requirement.md 需求 + 概要设计正文
|
|
7
|
+
* requirements/<req_id>/docs/<doc_id>.md 详细设计文档(一份需求可以有很多份)
|
|
8
|
+
* stories/<sty_id>/story.md 故事说明 + 验收标准
|
|
9
|
+
* bugs/<bug_id>/bug.md 缺陷正文(描述、复现、期望、实际、环境)
|
|
11
10
|
*
|
|
12
11
|
* 所有写操作走「临时文件 + rename」,避免中途崩溃留下半截文档。
|
|
13
12
|
*/
|
|
14
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
15
|
-
import { dirname, isAbsolute,
|
|
14
|
+
import { dirname, isAbsolute, posix, resolve, sep, win32 } from 'node:path';
|
|
16
15
|
import { describeError } from "../errors.js";
|
|
17
|
-
/** 文档根目录名,工作区内的隐藏目录。 */
|
|
18
|
-
export const DOC_ROOT = '.xirang-ai/dsh-teamflow';
|
|
19
16
|
/**
|
|
20
|
-
*
|
|
17
|
+
* 计算一份文档在文档根内的相对路径。
|
|
21
18
|
*
|
|
22
19
|
* @param kind - 文档种类
|
|
23
20
|
* @param ids - 该种类需要的标识(designDoc 需要需求 id 与文档 id)
|
|
@@ -27,17 +24,17 @@ export function docPath(kind, ids) {
|
|
|
27
24
|
const id = requireId(ids.id);
|
|
28
25
|
switch (kind) {
|
|
29
26
|
case 'requirement':
|
|
30
|
-
return
|
|
27
|
+
return `requirements/${id}/requirement.md`;
|
|
31
28
|
case 'story':
|
|
32
|
-
return
|
|
29
|
+
return `stories/${id}/story.md`;
|
|
33
30
|
case 'bug':
|
|
34
|
-
return
|
|
31
|
+
return `bugs/${id}/bug.md`;
|
|
35
32
|
case 'designDoc':
|
|
36
|
-
return
|
|
33
|
+
return `requirements/${requireId(ids.requirementId)}/docs/${id}.md`;
|
|
37
34
|
}
|
|
38
35
|
}
|
|
39
36
|
/**
|
|
40
|
-
*
|
|
37
|
+
* 把相对路径拼到文档根上;只接受插件自己生成的、以 `/` 分隔的文档相对路径。
|
|
41
38
|
*
|
|
42
39
|
* 拒绝 POSIX 绝对路径、Windows 盘符/UNC/反斜杠路径与任何 `..`,最后再做一次
|
|
43
40
|
* 词法 containment 校验,确保结果仍落在 `root` 之内。
|
|
@@ -46,10 +43,10 @@ export function resolveDocPath(root, relative) {
|
|
|
46
43
|
if (relative.length === 0)
|
|
47
44
|
throw new Error('teamflow: document path is empty');
|
|
48
45
|
if (relative.includes('\0') || relative.includes('\\')) {
|
|
49
|
-
throw new Error(`teamflow: document path must be
|
|
46
|
+
throw new Error(`teamflow: document path must be relative to the document root, got "${relative}"`);
|
|
50
47
|
}
|
|
51
48
|
if (isAbsolute(relative) || win32.isAbsolute(relative) || /^[A-Za-z]:/.test(relative)) {
|
|
52
|
-
throw new Error(`teamflow: document path must be
|
|
49
|
+
throw new Error(`teamflow: document path must be relative to the document root, got "${relative}"`);
|
|
53
50
|
}
|
|
54
51
|
const segments = relative.split('/');
|
|
55
52
|
if (segments.some((part) => part === '..' || part === '.' || part.length === 0)) {
|
|
@@ -58,27 +55,23 @@ export function resolveDocPath(root, relative) {
|
|
|
58
55
|
const base = resolve(root);
|
|
59
56
|
const absolute = resolve(base, ...segments);
|
|
60
57
|
if (absolute !== base && !absolute.startsWith(`${base}${sep}`)) {
|
|
61
|
-
throw new Error(`teamflow: document path escapes the
|
|
58
|
+
throw new Error(`teamflow: document path escapes the document root, got "${relative}"`);
|
|
62
59
|
}
|
|
63
60
|
return absolute;
|
|
64
61
|
}
|
|
65
|
-
/**
|
|
62
|
+
/** 文档相对路径的展示形式(日志与工具结果用,始终 `/`)。 */
|
|
66
63
|
export function displayPath(relative) {
|
|
67
64
|
return relative.split(sep).join(posix.sep);
|
|
68
65
|
}
|
|
69
66
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* 首次为文档根准备共享目录:只建 `requirements/`、`stories/`、`bugs/`,
|
|
68
|
+
* 不碰工作区,也不写 `.gitignore`。
|
|
72
69
|
*
|
|
73
|
-
* @param root -
|
|
70
|
+
* @param root - 文档根目录(绝对路径)
|
|
74
71
|
* @returns 实际创建的目录(相对路径),便于工具结果里说明
|
|
75
72
|
*/
|
|
76
73
|
export function ensureDocTree(root) {
|
|
77
|
-
const dirs = [
|
|
78
|
-
`${DOC_ROOT}/requirements`,
|
|
79
|
-
`${DOC_ROOT}/stories`,
|
|
80
|
-
`${DOC_ROOT}/bugs`,
|
|
81
|
-
];
|
|
74
|
+
const dirs = ['requirements', 'stories', 'bugs'];
|
|
82
75
|
const created = [];
|
|
83
76
|
for (const dir of dirs) {
|
|
84
77
|
const absolute = resolveDocPath(root, dir);
|
|
@@ -87,11 +80,6 @@ export function ensureDocTree(root) {
|
|
|
87
80
|
created.push(dir);
|
|
88
81
|
}
|
|
89
82
|
}
|
|
90
|
-
const localIgnore = resolveDocPath(root, `${DOC_ROOT}/.gitignore`);
|
|
91
|
-
if (!existsSync(localIgnore)) {
|
|
92
|
-
writeFileSync(localIgnore, '*\n', 'utf8');
|
|
93
|
-
}
|
|
94
|
-
appendWorkspaceGitignore(root);
|
|
95
83
|
return created;
|
|
96
84
|
}
|
|
97
85
|
/**
|
|
@@ -144,8 +132,8 @@ let stagedSequence = 0;
|
|
|
144
132
|
* 到临时名,再在 SQLite 事务里删记录:事务失败就把文件改回来,事务成功才真正
|
|
145
133
|
* `unlink`。这样任何时刻都不会出现「记录已经没了、正式路径上的文件还在」的悬挂态。
|
|
146
134
|
*
|
|
147
|
-
* @param root -
|
|
148
|
-
* @param relative -
|
|
135
|
+
* @param root - 文档根目录
|
|
136
|
+
* @param relative - 文档相对路径
|
|
149
137
|
* @returns 暂存信息;文件本来就不存在时返回 `null`
|
|
150
138
|
*/
|
|
151
139
|
export function stageDocDeletion(root, relative) {
|
|
@@ -182,10 +170,10 @@ export function dropStagedDoc(staged) {
|
|
|
182
170
|
* 只删空目录:目录里还有别的东西时立刻停下,避免误删用户手工放进来的文件。
|
|
183
171
|
*/
|
|
184
172
|
export function pruneEmptyDirectories(root, relative) {
|
|
185
|
-
const boundary =
|
|
173
|
+
const boundary = resolve(root);
|
|
186
174
|
let directory = dirname(resolveDocPath(root, relative));
|
|
187
175
|
// Stop before the shared `requirements/`, `stories/`, `bugs/` roots: those
|
|
188
|
-
// belong to the
|
|
176
|
+
// belong to the document tree, not to the record being deleted.
|
|
189
177
|
while (directory !== boundary && dirname(directory) !== boundary && directory.startsWith(`${boundary}${sep}`)) {
|
|
190
178
|
try {
|
|
191
179
|
if (readdirSync(directory).length > 0)
|
|
@@ -211,18 +199,3 @@ function requireId(id) {
|
|
|
211
199
|
}
|
|
212
200
|
return id;
|
|
213
201
|
}
|
|
214
|
-
/** 把 `.xirang-ai/` 追加到工作区 `.gitignore`;没有该文件就创建一个只含这一行的。 */
|
|
215
|
-
function appendWorkspaceGitignore(root) {
|
|
216
|
-
const ignore = join(root, '.gitignore');
|
|
217
|
-
const entry = '.xirang-ai/';
|
|
218
|
-
if (!existsSync(ignore)) {
|
|
219
|
-
writeFileSync(ignore, `${entry}\n`, 'utf8');
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
const current = readFileSync(ignore, 'utf8');
|
|
223
|
-
const lines = current.split('\n').map((line) => line.trim());
|
|
224
|
-
if (lines.includes(entry) || lines.includes('.xirang-ai'))
|
|
225
|
-
return;
|
|
226
|
-
const suffix = current.endsWith('\n') || current.length === 0 ? '' : '\n';
|
|
227
|
-
writeFileSync(ignore, `${current}${suffix}${entry}\n`, 'utf8');
|
|
228
|
-
}
|
package/lib/store/model.d.ts
CHANGED
|
@@ -198,6 +198,27 @@ export interface RequirementSession {
|
|
|
198
198
|
readonly linkedAt: string | null;
|
|
199
199
|
readonly createdAt: string;
|
|
200
200
|
}
|
|
201
|
+
/** Which kind of record a discussion conversation belongs to. */
|
|
202
|
+
export declare const DISCUSSION_SUBJECT_TYPES: readonly ["requirement", "story", "bug"];
|
|
203
|
+
export type DiscussionSubjectType = (typeof DISCUSSION_SUBJECT_TYPES)[number];
|
|
204
|
+
/** Discussion kind used by stories and defects; requirements keep their own kinds. */
|
|
205
|
+
export declare const GENERAL_DISCUSSION_KIND = "discuss";
|
|
206
|
+
/**
|
|
207
|
+
* One conversation registered against a requirement, story or defect.
|
|
208
|
+
*
|
|
209
|
+
* Rows start unconfirmed (`linkedAt` null) when the browser prepares a
|
|
210
|
+
* discussion and become visible once the Agent confirms with `linkedAt`.
|
|
211
|
+
*/
|
|
212
|
+
export interface DiscussionSession {
|
|
213
|
+
readonly subjectType: DiscussionSubjectType;
|
|
214
|
+
readonly subjectId: string;
|
|
215
|
+
readonly sessionId: string;
|
|
216
|
+
/** `create` / `design` / `detail` / `split` for requirements, `discuss` otherwise. */
|
|
217
|
+
readonly kind: string;
|
|
218
|
+
readonly preparedAt: string | null;
|
|
219
|
+
readonly linkedAt: string | null;
|
|
220
|
+
readonly createdAt: string;
|
|
221
|
+
}
|
|
201
222
|
/**
|
|
202
223
|
* One consistent read of every table the workbench renders.
|
|
203
224
|
*
|
|
@@ -214,6 +235,8 @@ export interface BoardRows {
|
|
|
214
235
|
readonly docStories: readonly DocStoryLink[];
|
|
215
236
|
/** Requirement conversations, newest first; design rows may still be unconfirmed. */
|
|
216
237
|
readonly requirementSessions: readonly RequirementSession[];
|
|
238
|
+
/** Confirmed story and defect discussions, newest first. */
|
|
239
|
+
readonly discussionSessions: readonly DiscussionSession[];
|
|
217
240
|
}
|
|
218
241
|
/** Input for {@link WorkflowStore.createDesignDoc}. */
|
|
219
242
|
export interface CreateDesignDocInput {
|
package/lib/store/model.js
CHANGED
|
@@ -41,6 +41,10 @@ export const BUG_SEVERITIES = ['blocker', 'critical', 'major', 'minor', 'trivial
|
|
|
41
41
|
export const DOC_KINDS = ['flow', 'data', 'interface', 'page', 'rule'];
|
|
42
42
|
/** Why a requirement is linked to a conversation. */
|
|
43
43
|
export const REQUIREMENT_SESSION_KINDS = ['create', 'design', 'detail', 'split'];
|
|
44
|
+
/** Which kind of record a discussion conversation belongs to. */
|
|
45
|
+
export const DISCUSSION_SUBJECT_TYPES = ['requirement', 'story', 'bug'];
|
|
46
|
+
/** Discussion kind used by stories and defects; requirements keep their own kinds. */
|
|
47
|
+
export const GENERAL_DISCUSSION_KIND = 'discuss';
|
|
44
48
|
/** A rejected domain operation, separate from SQLite or Host failures. */
|
|
45
49
|
export class WorkflowError extends Error {
|
|
46
50
|
/** Machine-readable reason for tools and the Web UI. */
|
package/lib/store/paths.d.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export declare const PLUGIN_HOME_SEGMENTS: readonly ["xirang-ai", "dsh-teamflow"];
|
|
3
3
|
/** Database file name inside the plugin data directory. */
|
|
4
4
|
export declare const DATABASE_FILENAME = "teamflow.sqlite";
|
|
5
|
+
/** Directory name of the markdown documents below the plugin data directory. */
|
|
6
|
+
export declare const DOCS_DIRNAME = "docs";
|
|
5
7
|
/** Human-readable directory of the durable data, e.g. `$DSH_HOME/xirang-ai/dsh-teamflow`. */
|
|
6
8
|
export declare function defaultDataDir(): string;
|
|
7
9
|
/**
|
|
@@ -13,6 +15,24 @@ export declare function defaultDataDir(): string;
|
|
|
13
15
|
* @returns the absolute data directory.
|
|
14
16
|
*/
|
|
15
17
|
export declare function resolveDataDir(configured?: string): string;
|
|
18
|
+
/** Default document root: `<dataDir>/docs`, next to the database. */
|
|
19
|
+
export declare function defaultDocsDir(dataDir: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the document root: an explicit `Config.docsDir` wins, otherwise
|
|
22
|
+
* `<dataDir>/docs`. Relative overrides resolve against the process working
|
|
23
|
+
* directory and `~/` is expanded, mirroring {@link resolveDataDir}.
|
|
24
|
+
*
|
|
25
|
+
* Validation is loud on purpose: a malformed override or a path that is
|
|
26
|
+
* already a file fails plugin load with the field name and the accepted forms
|
|
27
|
+
* instead of silently falling back to the default directory.
|
|
28
|
+
*
|
|
29
|
+
* @param configured - `Config.docsDir`, possibly empty.
|
|
30
|
+
* @param dataDir - the resolved data directory.
|
|
31
|
+
* @returns the absolute document root.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveDocsDir(configured: string | undefined, dataDir: string): string;
|
|
34
|
+
/** Create the document root (owner-only) and return it. */
|
|
35
|
+
export declare function ensureDocsDir(docsDir: string): string;
|
|
16
36
|
/** Absolute database file path for a resolved data directory. */
|
|
17
37
|
export declare function databaseFile(dataDir: string): string;
|
|
18
38
|
/** Create the data directory (owner-only) and return it. */
|
package/lib/store/paths.js
CHANGED
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
12
|
-
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join, resolve, sep } from 'node:path';
|
|
15
|
+
import { describeError } from "../errors.js";
|
|
15
16
|
/** Path segments below `$DSH_HOME` that this plugin owns. */
|
|
16
17
|
export const PLUGIN_HOME_SEGMENTS = ['xirang-ai', 'dsh-teamflow'];
|
|
17
18
|
/** Database file name inside the plugin data directory. */
|
|
18
19
|
export const DATABASE_FILENAME = 'teamflow.sqlite';
|
|
20
|
+
/** Directory name of the markdown documents below the plugin data directory. */
|
|
21
|
+
export const DOCS_DIRNAME = 'docs';
|
|
19
22
|
/** Human-readable directory of the durable data, e.g. `$DSH_HOME/xirang-ai/dsh-teamflow`. */
|
|
20
23
|
export function defaultDataDir() {
|
|
21
24
|
return dshHomePath(...PLUGIN_HOME_SEGMENTS);
|
|
@@ -38,6 +41,56 @@ export function resolveDataDir(configured) {
|
|
|
38
41
|
return join(homedir(), trimmed.slice(2));
|
|
39
42
|
return resolve(trimmed);
|
|
40
43
|
}
|
|
44
|
+
/** Default document root: `<dataDir>/docs`, next to the database. */
|
|
45
|
+
export function defaultDocsDir(dataDir) {
|
|
46
|
+
return join(dataDir, DOCS_DIRNAME);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the document root: an explicit `Config.docsDir` wins, otherwise
|
|
50
|
+
* `<dataDir>/docs`. Relative overrides resolve against the process working
|
|
51
|
+
* directory and `~/` is expanded, mirroring {@link resolveDataDir}.
|
|
52
|
+
*
|
|
53
|
+
* Validation is loud on purpose: a malformed override or a path that is
|
|
54
|
+
* already a file fails plugin load with the field name and the accepted forms
|
|
55
|
+
* instead of silently falling back to the default directory.
|
|
56
|
+
*
|
|
57
|
+
* @param configured - `Config.docsDir`, possibly empty.
|
|
58
|
+
* @param dataDir - the resolved data directory.
|
|
59
|
+
* @returns the absolute document root.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveDocsDir(configured, dataDir) {
|
|
62
|
+
const trimmed = configured?.trim() ?? '';
|
|
63
|
+
if (trimmed === '')
|
|
64
|
+
return defaultDocsDir(dataDir);
|
|
65
|
+
const expected = 'an absolute path, a path relative to the working directory, or a path starting with "~/"';
|
|
66
|
+
if (trimmed.includes('\0')) {
|
|
67
|
+
throw new Error(`teamflow: config "docsDir" must be ${expected}, got ${JSON.stringify(configured)}`);
|
|
68
|
+
}
|
|
69
|
+
let absolute;
|
|
70
|
+
if (trimmed === '~') {
|
|
71
|
+
absolute = homedir();
|
|
72
|
+
}
|
|
73
|
+
else if (trimmed.startsWith(`~${sep}`) || trimmed.startsWith('~/')) {
|
|
74
|
+
absolute = join(homedir(), trimmed.slice(2));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
absolute = resolve(trimmed);
|
|
78
|
+
}
|
|
79
|
+
if (existsSync(absolute) && !statSync(absolute).isDirectory()) {
|
|
80
|
+
throw new Error(`teamflow: config "docsDir" must point to a directory, but ${toDisplayPath(absolute)} is a file`);
|
|
81
|
+
}
|
|
82
|
+
return absolute;
|
|
83
|
+
}
|
|
84
|
+
/** Create the document root (owner-only) and return it. */
|
|
85
|
+
export function ensureDocsDir(docsDir) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(docsDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new Error(`teamflow: could not create the docs directory ${toDisplayPath(docsDir)} (config "docsDir"): ${describeError(error)}`, { cause: error });
|
|
91
|
+
}
|
|
92
|
+
return docsDir;
|
|
93
|
+
}
|
|
41
94
|
/** Absolute database file path for a resolved data directory. */
|
|
42
95
|
export function databaseFile(dataDir) {
|
|
43
96
|
return join(dataDir, DATABASE_FILENAME);
|
package/lib/store/schema.js
CHANGED
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { dirname, join } from 'node:path';
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Document root of the released v10 migration. Migrations are a historical
|
|
27
|
+
* contract: v10 moved bodies into the workspace, and that path must stay
|
|
28
|
+
* exactly as published even though current documents live elsewhere.
|
|
29
|
+
*/
|
|
30
|
+
const LEGACY_DOC_ROOT = '.xirang-ai/dsh-teamflow';
|
|
26
31
|
/**
|
|
27
32
|
* Roles every fresh database starts with. `name` is the default label; the
|
|
28
33
|
* Workbench UI localizes built-in keys through its locale dictionaries, so a
|
|
@@ -564,7 +569,7 @@ function relocateV10Bodies(db, designs, docs) {
|
|
|
564
569
|
updateRequirement.run(primary.contentPath, requirementId);
|
|
565
570
|
continue;
|
|
566
571
|
}
|
|
567
|
-
const target = `${
|
|
572
|
+
const target = `${LEGACY_DOC_ROOT}/requirements/${requirementId}/requirement.md`;
|
|
568
573
|
try {
|
|
569
574
|
let body = readFileSync(absolute(root, primary.contentPath), 'utf8');
|
|
570
575
|
for (const extra of group.slice(1)) {
|
|
@@ -589,7 +594,7 @@ function relocateV10Bodies(db, designs, docs) {
|
|
|
589
594
|
const root = roots.get(requirementId);
|
|
590
595
|
if (root === undefined || doc.contentPath === '')
|
|
591
596
|
continue;
|
|
592
|
-
const target = `${
|
|
597
|
+
const target = `${LEGACY_DOC_ROOT}/requirements/${requirementId}/docs/${doc.id}.md`;
|
|
593
598
|
try {
|
|
594
599
|
if (!existsSync(absolute(root, doc.contentPath)))
|
|
595
600
|
continue;
|
|
@@ -809,6 +814,50 @@ const SCHEMA_V16 = `
|
|
|
809
814
|
ALTER TABLE stories ADD COLUMN create_session_id TEXT; -- 创建该故事的 DSH 会话 id;历史数据为 NULL
|
|
810
815
|
ALTER TABLE bugs ADD COLUMN create_session_id TEXT; -- 创建该缺陷的 DSH 会话 id;历史数据为 NULL
|
|
811
816
|
`;
|
|
817
|
+
/**
|
|
818
|
+
* v17:讨论会话登记泛化为需求 / 故事 / 缺陷三类对象。
|
|
819
|
+
*
|
|
820
|
+
* 需求专属的 `requirement_sessions` 只能挂需求,而故事与缺陷也要能从详情页
|
|
821
|
+
* 发起讨论并回写正文。这里换成一张按 `(subject_type, subject_id, session_id)`
|
|
822
|
+
* 唯一的通用表:需求侧原有的 kind(create/design/detail/split)原样迁入,
|
|
823
|
+
* 故事与缺陷用 `discuss`。时间列沿用旧表的 TEXT ISO-8601,迁移不换算。
|
|
824
|
+
* 对象删除不再级联:列表按 `(subject_type, subject_id)` 查询,孤儿行不进任何列表。
|
|
825
|
+
*/
|
|
826
|
+
const SCHEMA_V17 = `
|
|
827
|
+
CREATE TABLE discussion_sessions ( -- 讨论对象与宿主会话的登记关系表
|
|
828
|
+
subject_type TEXT NOT NULL, -- 讨论对象类型:requirement / story / bug
|
|
829
|
+
subject_id TEXT NOT NULL, -- 讨论对象 id(需求 / 故事 / 缺陷的业务 id)
|
|
830
|
+
session_id TEXT NOT NULL, -- 宿主会话 id
|
|
831
|
+
kind TEXT NOT NULL DEFAULT 'discuss', -- 讨论类型:discuss 为通用讨论;需求侧沿用既有取值
|
|
832
|
+
prepared_at TEXT, -- 两段式登记第一段:客户端写入的「待开始」时刻(ISO-8601 UTC);创建会话行为 NULL
|
|
833
|
+
linked_at TEXT, -- 两段式登记第二段:Agent 确认后的「已登记」时刻(ISO-8601 UTC);NULL = 待开始
|
|
834
|
+
created_at TEXT NOT NULL, -- 行首次创建时刻(ISO-8601 UTC)
|
|
835
|
+
PRIMARY KEY (subject_type, subject_id, session_id)
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
CREATE INDEX idx_discussion_sessions_subject
|
|
839
|
+
ON discussion_sessions (subject_type, subject_id, linked_at DESC);
|
|
840
|
+
`;
|
|
841
|
+
/** Copy every legacy requirement row into the generic table and drop it. */
|
|
842
|
+
function migrateRequirementSessions(db) {
|
|
843
|
+
db.exec(SCHEMA_V17);
|
|
844
|
+
db.exec(`
|
|
845
|
+
INSERT INTO discussion_sessions
|
|
846
|
+
(subject_type, subject_id, kind, session_id, prepared_at, linked_at, created_at)
|
|
847
|
+
SELECT 'requirement', requirement_id, kind, session_id, prepared_at, linked_at, created_at
|
|
848
|
+
FROM requirement_sessions;
|
|
849
|
+
`);
|
|
850
|
+
const before = db.prepare('SELECT COUNT(*) AS count FROM requirement_sessions').get();
|
|
851
|
+
const after = db
|
|
852
|
+
.prepare("SELECT COUNT(*) AS count FROM discussion_sessions WHERE subject_type = 'requirement'")
|
|
853
|
+
.get();
|
|
854
|
+
const copied = Number(after?.['count'] ?? 0);
|
|
855
|
+
const expected = Number(before?.['count'] ?? 0);
|
|
856
|
+
if (copied !== expected) {
|
|
857
|
+
throw new Error(`teamflow: schema migration 17 copied ${copied} requirement session row(s) but the legacy table held ${expected}; rolling back`);
|
|
858
|
+
}
|
|
859
|
+
db.exec('DROP TABLE requirement_sessions');
|
|
860
|
+
}
|
|
812
861
|
export const MIGRATIONS = [
|
|
813
862
|
{
|
|
814
863
|
version: 1,
|
|
@@ -944,6 +993,13 @@ export const MIGRATIONS = [
|
|
|
944
993
|
db.exec(SCHEMA_V16);
|
|
945
994
|
},
|
|
946
995
|
},
|
|
996
|
+
{
|
|
997
|
+
version: 17,
|
|
998
|
+
description: 'generalize discussion sessions to requirements, stories and bugs',
|
|
999
|
+
up: (db) => {
|
|
1000
|
+
migrateRequirementSessions(db);
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
947
1003
|
];
|
|
948
1004
|
/** Schema version this build expects; the last migration's version. */
|
|
949
1005
|
export const TARGET_SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0;
|
|
@@ -985,24 +1041,25 @@ export const SCHEMA_COMMENTS = [
|
|
|
985
1041
|
{ table: 'requirements', column: 'workspace_id', description: '所属 DSH 工作区 id(ctx.workspaces 的 WorkspaceView.workspaceId);空串表示 v4 迁移之前写入的历史数据。' },
|
|
986
1042
|
{ table: 'requirements', column: 'title', description: '需求标题。' },
|
|
987
1043
|
{ table: 'requirements', column: 'summary', description: '澄清并总结后的一句话结论,供详细设计和故事引用。' },
|
|
988
|
-
{ table: 'requirements', column: 'content_path', description: '正文(需求 +
|
|
1044
|
+
{ table: 'requirements', column: 'content_path', description: '正文(需求 + 概要设计)相对路径(文档根内,如 requirements/<id>/requirement.md)。' },
|
|
989
1045
|
{ table: 'requirements', column: 'status', description: '状态:draft=进行中(仍在收集或修改),completed=用户确认完成,archived=已归档;完成不会自动发生。' },
|
|
990
1046
|
{ table: 'requirements', column: 'created_at', description: '创建时间(ISO-8601 UTC)。' },
|
|
991
1047
|
{ table: 'requirements', column: 'updated_at', description: '最后更新时间(ISO-8601 UTC)。' },
|
|
992
1048
|
{ table: 'requirements', column: 'completed_at', description: '用户确认完成需求的时间(ISO-8601 UTC),未完成为 NULL。' },
|
|
993
|
-
{ table: '
|
|
994
|
-
{ table: '
|
|
995
|
-
{ table: '
|
|
996
|
-
{ table: '
|
|
997
|
-
{ table: '
|
|
998
|
-
{ table: '
|
|
999
|
-
{ table: '
|
|
1049
|
+
{ table: 'discussion_sessions', column: '', description: '讨论对象与宿主会话的登记关系:需求、故事、缺陷的讨论会话都记在这里,两段式登记(浏览器准备、Agent 确认)。' },
|
|
1050
|
+
{ table: 'discussion_sessions', column: 'subject_type', description: '讨论对象类型:requirement=需求,story=故事,bug=缺陷;与 subject_id 一起标识对象。' },
|
|
1051
|
+
{ table: 'discussion_sessions', column: 'subject_id', description: '讨论对象 id(需求 / 故事 / 缺陷的业务 id);对象删除时不级联清理。' },
|
|
1052
|
+
{ table: 'discussion_sessions', column: 'session_id', description: 'DSH 会话 id;同一对象与同一会话只有一行。' },
|
|
1053
|
+
{ table: 'discussion_sessions', column: 'kind', description: '讨论类型:create=创建需求,design=讨论概要设计,detail=讨论详细设计,split=讨论故事拆分,discuss=故事/缺陷的通用讨论。' },
|
|
1054
|
+
{ table: 'discussion_sessions', column: 'prepared_at', description: '浏览器创建讨论会话后写入待登记的时间(ISO-8601 UTC);创建需求的会话为 NULL。' },
|
|
1055
|
+
{ table: 'discussion_sessions', column: 'linked_at', description: 'Agent 调用登记工具确认的时间(ISO-8601 UTC);NULL 表示待开始,不进入历史列表。' },
|
|
1056
|
+
{ table: 'discussion_sessions', column: 'created_at', description: '首次绑定时间(ISO-8601 UTC)。' },
|
|
1000
1057
|
{ table: 'stories', column: '', description: '故事:可独立开发与交付的单元;父级是需求,也可以独立存在。' },
|
|
1001
1058
|
{ table: 'stories', column: 'id', description: 'nanoid 业务主键(sty_ 前缀)。' },
|
|
1002
1059
|
{ table: 'stories', column: 'workspace_id', description: '所属 DSH 工作区 id:有父级时继承并校验一致,独立故事创建时指定。' },
|
|
1003
1060
|
{ table: 'stories', column: 'requirement_id', description: '所属需求;为空表示用户直接提出的独立故事。' },
|
|
1004
1061
|
{ table: 'stories', column: 'title', description: '故事标题。' },
|
|
1005
|
-
{ table: 'stories', column: 'content_path', description: '
|
|
1062
|
+
{ table: 'stories', column: 'content_path', description: '故事正文相对路径(文档根内,如 stories/<id>/story.md):说明与验收标准写在同一份 story.md 里。' },
|
|
1006
1063
|
{ table: 'stories', column: 'status', description: '状态:ready=待开发,developing=开发中,self_testing=自测中,done=执行完毕(未完成),completed=用户确认完成,cancelled=已取消。' },
|
|
1007
1064
|
{ table: 'stories', column: 'size', description: '拆分时的工作量档位:xs/s/m/l/xl。' },
|
|
1008
1065
|
{ table: 'stories', column: 'sort_order', description: '同一需求下的展示顺序,升序;新建取当前最大值 + 1,运行与状态流转不改它。' },
|
|
@@ -1013,7 +1070,7 @@ export const SCHEMA_COMMENTS = [
|
|
|
1013
1070
|
{ table: 'design_docs', column: 'requirement_id', description: '所属需求;删除需求时一并删除其文档。' },
|
|
1014
1071
|
{ table: 'design_docs', column: 'kind', description: '文档主题:flow=流程,data=数据,interface=接口,page=页面,rule=规则。' },
|
|
1015
1072
|
{ table: 'design_docs', column: 'title', description: '文档标题,工作台列表中一行显示。' },
|
|
1016
|
-
{ table: 'design_docs', column: 'content_path', description: '
|
|
1073
|
+
{ table: 'design_docs', column: 'content_path', description: '详设正文相对路径(文档根内,如 requirements/<req>/docs/<doc>.md);正文很长,只落盘不进库。' },
|
|
1017
1074
|
{ table: 'design_docs', column: 'sort_order', description: '同一需求下的展示顺序,升序;新建取当前最大值 + 1,页面只按它渲染。' },
|
|
1018
1075
|
{ table: 'design_docs', column: 'created_at', description: '创建时间(ISO-8601 UTC)。' },
|
|
1019
1076
|
{ table: 'design_docs', column: 'updated_at', description: '最后更新时间(ISO-8601 UTC)。' },
|
|
@@ -1028,7 +1085,7 @@ export const SCHEMA_COMMENTS = [
|
|
|
1028
1085
|
{ table: 'bugs', column: 'id', description: 'nanoid 业务主键(bug_ 前缀)。' },
|
|
1029
1086
|
{ table: 'bugs', column: 'workspace_id', description: '所属 DSH 工作区 id:bug 独立存在,创建时必须显式给出。' },
|
|
1030
1087
|
{ table: 'bugs', column: 'title', description: 'bug 标题。' },
|
|
1031
|
-
{ table: 'bugs', column: 'content_path', description: '
|
|
1088
|
+
{ table: 'bugs', column: 'content_path', description: '缺陷正文相对路径(文档根内,如 bugs/<id>/bug.md):描述、复现、期望、实际、环境都在这份 bug.md 里。' },
|
|
1032
1089
|
{ table: 'bugs', column: 'severity', description: '严重程度:blocker/critical/major/minor/trivial。' },
|
|
1033
1090
|
{ table: 'bugs', column: 'status', description: '状态:reported=待修复,fixing=修复中,fixed=执行完毕(未完成),completed=用户确认完成,wontfix=不修复。' },
|
|
1034
1091
|
{ table: 'bugs', column: 'resolution', description: '修复结论、不修复原因或关闭说明。' },
|
package/lib/store/workflow.d.ts
CHANGED
|
@@ -19,17 +19,16 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { DatabaseSync } from 'node:sqlite';
|
|
21
21
|
import { type PermissionTier } from '../permission-tier.ts';
|
|
22
|
-
import { type Bug, type BugFilter, type BoardRows, type DeletePreview, type DeleteReport, type DeleteTarget, type CreateBugInput, type CreateDesignDocInput, type CreateRequirementInput, type CreateStoryInput, type DesignDoc, type FinishRunInput, type Requirement, type RequirementSession, type RequirementSessionKind, type UpdateDesignDocInput, type RequirementFilter, type Run, type RunFilter, type StartRunInput, type Story, type StoryFilter, type StoryReadiness, type UpdateBugInput, type UpdateRequirementInput, type UpdateStoryInput } from './model.ts';
|
|
22
|
+
import { type Bug, type BugFilter, type BoardRows, type DeletePreview, type DeleteReport, type DeleteTarget, type DiscussionSession, type DiscussionSubjectType, type CreateBugInput, type CreateDesignDocInput, type CreateRequirementInput, type CreateStoryInput, type DesignDoc, type FinishRunInput, type Requirement, type RequirementSession, type RequirementSessionKind, type UpdateDesignDocInput, type RequirementFilter, type Run, type RunFilter, type StartRunInput, type Story, type StoryFilter, type StoryReadiness, type UpdateBugInput, type UpdateRequirementInput, type UpdateStoryInput } from './model.ts';
|
|
23
23
|
/** Cap one batch so a single call cannot scan an unbounded id list. */
|
|
24
24
|
export declare const MAX_DELETE_TARGETS = 50;
|
|
25
25
|
/** Construction options for {@link WorkflowStore}. */
|
|
26
26
|
export interface WorkflowStoreOptions {
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* workspace path instead (see {@link WorkflowStore.registerWorkspace}).
|
|
28
|
+
* Absolute document root that holds `requirements/`, `stories/` and
|
|
29
|
+
* `bugs/`. Every body lives here; records only store the root-relative path.
|
|
31
30
|
*/
|
|
32
|
-
readonly docsRoot
|
|
31
|
+
readonly docsRoot: string;
|
|
33
32
|
/** Resolver used before the registry; lets the Host provide the live path. */
|
|
34
33
|
readonly workspaceRoot?: (workspaceId: string) => string | undefined;
|
|
35
34
|
/** Clock override for tests; must return an ISO-8601 UTC string. */
|
|
@@ -45,52 +44,78 @@ export interface WorkflowStoreOptions {
|
|
|
45
44
|
*/
|
|
46
45
|
export declare class WorkflowStore {
|
|
47
46
|
#private;
|
|
48
|
-
constructor(db: DatabaseSync, options
|
|
47
|
+
constructor(db: DatabaseSync, options: WorkflowStoreOptions);
|
|
49
48
|
/**
|
|
50
|
-
* Remember where a workspace lives on disk.
|
|
51
|
-
*
|
|
52
|
-
* repeated calls with the same path are no-ops.
|
|
49
|
+
* Remember where a workspace lives on disk. Document bodies no longer live
|
|
50
|
+
* there, but the path is still recorded for display and for future
|
|
51
|
+
* workspace-scoped features; repeated calls with the same path are no-ops.
|
|
53
52
|
*/
|
|
54
53
|
registerWorkspace(workspaceId: string, path: string): void;
|
|
55
|
-
/** Directory a workspace
|
|
54
|
+
/** Directory a registered workspace lives in, or null when unknown. */
|
|
56
55
|
workspacePath(workspaceId: string): string | null;
|
|
57
56
|
/**
|
|
58
|
-
* Read a document body by its
|
|
57
|
+
* Read a document body by its document-root-relative path.
|
|
59
58
|
*
|
|
60
59
|
* `null` means the file does not exist; `''` means it exists and is empty.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* either empty result.
|
|
60
|
+
* Every record resolves against the one configured document root, so a
|
|
61
|
+
* legacy path simply misses instead of falling back to a workspace.
|
|
64
62
|
*/
|
|
65
|
-
readContent(
|
|
63
|
+
readContent(relativePath: string): string | null;
|
|
66
64
|
/** Rewrite a document body; the path comes from the row, so it is always current. */
|
|
67
|
-
writeContent(
|
|
65
|
+
writeContent(relativePath: string, body: string): void;
|
|
68
66
|
getRequirement(id: string): Requirement | null;
|
|
69
67
|
listRequirements(filter?: RequirementFilter): Requirement[];
|
|
70
68
|
/**
|
|
71
69
|
* Register a browser-created session as a pending discussion for a requirement.
|
|
72
70
|
*
|
|
71
|
+
* Kept as a thin wrapper over {@link prepareDiscussionSession} so the
|
|
72
|
+
* requirement page and its tests keep their existing shape.
|
|
73
|
+
*/
|
|
74
|
+
prepareRequirementSession(requirementId: string, sessionId: string, kind?: RequirementSessionKind): RequirementSession;
|
|
75
|
+
/**
|
|
76
|
+
* Register a browser-created session as a pending discussion for a subject.
|
|
77
|
+
*
|
|
73
78
|
* The browser creates the session first, then calls this before the user can
|
|
74
79
|
* send the first message. The row stays invisible in `listBoard` until the
|
|
75
|
-
* Agent confirms it through
|
|
80
|
+
* Agent confirms it through `teamflow_link_session`; this method never writes
|
|
81
|
+
* `linked_at`, so the client cannot skip Agent confirmation.
|
|
76
82
|
*/
|
|
77
|
-
|
|
83
|
+
prepareDiscussionSession(subjectType: DiscussionSubjectType, subjectId: string, sessionId: string, kind: string): DiscussionSession;
|
|
78
84
|
/**
|
|
79
|
-
* Confirm the current session as
|
|
85
|
+
* Confirm the current session as the discussion it was prepared for.
|
|
80
86
|
*
|
|
81
|
-
* Called by `
|
|
82
|
-
*
|
|
87
|
+
* Called by `teamflow_link_session`; repeated calls are idempotent so a model
|
|
88
|
+
* retry cannot rewrite the confirmation time.
|
|
89
|
+
*/
|
|
90
|
+
linkDiscussionSession(sessionId: string): DiscussionSession;
|
|
91
|
+
/**
|
|
92
|
+
* Confirm the current session as a requirement discussion.
|
|
93
|
+
*
|
|
94
|
+
* Kept for `teamflow_link_requirement_session`; new callers use
|
|
95
|
+
* {@link linkDiscussionSession}.
|
|
83
96
|
*/
|
|
84
97
|
linkRequirementSession(sessionId: string): RequirementSession;
|
|
85
|
-
/** Pending or linked conversation prepared for one session, or null. */
|
|
98
|
+
/** Pending or linked requirement conversation prepared for one session, or null. */
|
|
86
99
|
getRequirementSession(sessionId: string): RequirementSession | null;
|
|
87
100
|
/** The requirement linked to a session, whichever kind of link it is. */
|
|
88
101
|
getRequirementBySession(sessionId: string): Requirement | null;
|
|
102
|
+
/** The discussion row bound to one session, or null when none exists. */
|
|
103
|
+
getDiscussionSession(sessionId: string): DiscussionSession | null;
|
|
104
|
+
/**
|
|
105
|
+
* Discussions under one subject, newest linked first.
|
|
106
|
+
*
|
|
107
|
+
* Pending rows are excluded by default so the workbench never shows a session
|
|
108
|
+
* the Agent has not started; tests and diagnostics can opt in.
|
|
109
|
+
*/
|
|
110
|
+
listDiscussionSessions(subjectType: DiscussionSubjectType, subjectId: string, options?: {
|
|
111
|
+
readonly includeUnlinked?: boolean;
|
|
112
|
+
readonly kinds?: readonly string[];
|
|
113
|
+
}): DiscussionSession[];
|
|
89
114
|
/**
|
|
90
|
-
*
|
|
115
|
+
* Requirement conversations under one requirement, newest linked first.
|
|
91
116
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
117
|
+
* The `create` row is always included: it explains where the requirement came
|
|
118
|
+
* from even though the Agent never confirms it.
|
|
94
119
|
*/
|
|
95
120
|
listRequirementSessions(requirementId: string, options?: {
|
|
96
121
|
readonly includePending?: boolean;
|