@dexto/tools-plan 1.5.8 → 1.6.1
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/dist/errors.cjs +126 -0
- package/dist/errors.js +99 -64
- package/dist/index.cjs +36 -0
- package/dist/index.d.cts +224 -0
- package/dist/index.d.ts +1 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -39
- package/dist/plan-service-getter.cjs +16 -0
- package/dist/plan-service-getter.d.ts +4 -0
- package/dist/plan-service-getter.d.ts.map +1 -0
- package/dist/plan-service-getter.js +0 -0
- package/dist/plan-service.cjs +247 -0
- package/dist/plan-service.d.ts +2 -2
- package/dist/plan-service.d.ts.map +1 -1
- package/dist/plan-service.js +201 -215
- package/dist/plan-service.test.cjs +227 -0
- package/dist/plan-service.test.js +200 -216
- package/dist/tool-factory-config.cjs +38 -0
- package/dist/tool-factory-config.d.ts +32 -0
- package/dist/tool-factory-config.d.ts.map +1 -0
- package/dist/tool-factory-config.js +13 -0
- package/dist/tool-factory.cjs +71 -0
- package/dist/tool-factory.d.ts +4 -0
- package/dist/tool-factory.d.ts.map +1 -0
- package/dist/tool-factory.js +40 -0
- package/dist/tool-factory.test.cjs +96 -0
- package/dist/tool-factory.test.d.ts +7 -0
- package/dist/tool-factory.test.d.ts.map +1 -0
- package/dist/tool-factory.test.js +95 -0
- package/dist/tools/plan-create-tool.cjs +102 -0
- package/dist/tools/plan-create-tool.d.ts +15 -3
- package/dist/tools/plan-create-tool.d.ts.map +1 -1
- package/dist/tools/plan-create-tool.js +77 -71
- package/dist/tools/plan-create-tool.test.cjs +174 -0
- package/dist/tools/plan-create-tool.test.js +142 -109
- package/dist/tools/plan-read-tool.cjs +65 -0
- package/dist/tools/plan-read-tool.d.ts +6 -3
- package/dist/tools/plan-read-tool.d.ts.map +1 -1
- package/dist/tools/plan-read-tool.js +39 -38
- package/dist/tools/plan-read-tool.test.cjs +109 -0
- package/dist/tools/plan-read-tool.test.js +78 -75
- package/dist/tools/plan-review-tool.cjs +98 -0
- package/dist/tools/plan-review-tool.d.ts +14 -5
- package/dist/tools/plan-review-tool.d.ts.map +1 -1
- package/dist/tools/plan-review-tool.js +73 -83
- package/dist/tools/plan-update-tool.cjs +92 -0
- package/dist/tools/plan-update-tool.d.ts +12 -3
- package/dist/tools/plan-update-tool.d.ts.map +1 -1
- package/dist/tools/plan-update-tool.js +65 -69
- package/dist/tools/plan-update-tool.test.cjs +203 -0
- package/dist/tools/plan-update-tool.test.js +171 -142
- package/dist/types.cjs +44 -0
- package/dist/types.js +17 -24
- package/package.json +8 -8
- package/.dexto-plugin/plugin.json +0 -7
- package/dist/tool-provider.d.ts +0 -44
- package/dist/tool-provider.d.ts.map +0 -1
- package/dist/tool-provider.js +0 -81
- package/dist/tool-provider.test.d.ts +0 -7
- package/dist/tool-provider.test.d.ts.map +0 -1
- package/dist/tool-provider.test.js +0 -185
- package/skills/plan/SKILL.md +0 -102
package/dist/plan-service.js
CHANGED
|
@@ -1,227 +1,213 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
basePath;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { PlanMetaSchema } from "./types.js";
|
|
5
|
+
import { PlanError } from "./errors.js";
|
|
6
|
+
const PLAN_FILENAME = "plan.md";
|
|
7
|
+
const META_FILENAME = "plan-meta.json";
|
|
8
|
+
class PlanService {
|
|
9
|
+
basePath;
|
|
10
|
+
logger;
|
|
11
|
+
constructor(options, logger) {
|
|
12
|
+
this.basePath = options.basePath;
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolves and validates a session directory path.
|
|
17
|
+
* Prevents path traversal attacks by ensuring the resolved path stays within basePath.
|
|
18
|
+
*/
|
|
19
|
+
resolveSessionDir(sessionId) {
|
|
20
|
+
const base = path.resolve(this.basePath);
|
|
21
|
+
const resolved = path.resolve(base, sessionId);
|
|
22
|
+
const rel = path.relative(base, resolved);
|
|
23
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
24
|
+
throw PlanError.invalidSessionId(sessionId);
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Gets the directory path for a session's plan
|
|
30
|
+
*/
|
|
31
|
+
getPlanDir(sessionId) {
|
|
32
|
+
return this.resolveSessionDir(sessionId);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gets the path to the plan content file.
|
|
36
|
+
* Public accessor for tools that need to display the path.
|
|
37
|
+
*/
|
|
38
|
+
getPlanPath(sessionId) {
|
|
39
|
+
return path.join(this.getPlanDir(sessionId), PLAN_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Gets the path to the plan metadata file
|
|
43
|
+
*/
|
|
44
|
+
getMetaPath(sessionId) {
|
|
45
|
+
return path.join(this.getPlanDir(sessionId), META_FILENAME);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Checks if a plan exists for the given session
|
|
49
|
+
*/
|
|
50
|
+
async exists(sessionId) {
|
|
51
|
+
const planPath = this.getPlanPath(sessionId);
|
|
52
|
+
return existsSync(planPath);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new plan for the session
|
|
56
|
+
*
|
|
57
|
+
* @throws PlanError.planAlreadyExists if plan already exists
|
|
58
|
+
* @throws PlanError.storageError on filesystem errors
|
|
59
|
+
*/
|
|
60
|
+
async create(sessionId, content, options) {
|
|
61
|
+
if (await this.exists(sessionId)) {
|
|
62
|
+
throw PlanError.planAlreadyExists(sessionId);
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
const planDir = this.getPlanDir(sessionId);
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const meta = {
|
|
67
|
+
sessionId,
|
|
68
|
+
status: "draft",
|
|
69
|
+
title: options?.title,
|
|
70
|
+
createdAt: now,
|
|
71
|
+
updatedAt: now
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
await fs.mkdir(planDir, { recursive: true });
|
|
75
|
+
await Promise.all([
|
|
76
|
+
fs.writeFile(this.getPlanPath(sessionId), content, "utf-8"),
|
|
77
|
+
fs.writeFile(this.getMetaPath(sessionId), JSON.stringify(meta, null, 2), "utf-8")
|
|
78
|
+
]);
|
|
79
|
+
this.logger.debug(`Created plan for session ${sessionId}`);
|
|
80
|
+
return { content, meta };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw PlanError.storageError("create", sessionId, error);
|
|
45
83
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reads the plan for the given session
|
|
87
|
+
*
|
|
88
|
+
* @returns The plan or null if not found
|
|
89
|
+
*/
|
|
90
|
+
async read(sessionId) {
|
|
91
|
+
if (!await this.exists(sessionId)) {
|
|
92
|
+
return null;
|
|
52
93
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Creates a new plan for the session
|
|
68
|
-
*
|
|
69
|
-
* @throws PlanError.planAlreadyExists if plan already exists
|
|
70
|
-
* @throws PlanError.storageError on filesystem errors
|
|
71
|
-
*/
|
|
72
|
-
async create(sessionId, content, options) {
|
|
73
|
-
// Check if plan already exists
|
|
74
|
-
if (await this.exists(sessionId)) {
|
|
75
|
-
throw PlanError.planAlreadyExists(sessionId);
|
|
76
|
-
}
|
|
77
|
-
const planDir = this.getPlanDir(sessionId);
|
|
78
|
-
const now = Date.now();
|
|
79
|
-
// Create metadata
|
|
80
|
-
const meta = {
|
|
94
|
+
try {
|
|
95
|
+
const [content, metaContent] = await Promise.all([
|
|
96
|
+
fs.readFile(this.getPlanPath(sessionId), "utf-8"),
|
|
97
|
+
fs.readFile(this.getMetaPath(sessionId), "utf-8")
|
|
98
|
+
]);
|
|
99
|
+
const metaParsed = JSON.parse(metaContent);
|
|
100
|
+
const metaResult = PlanMetaSchema.safeParse(metaParsed);
|
|
101
|
+
if (!metaResult.success) {
|
|
102
|
+
this.logger.warn(`Invalid plan metadata for session ${sessionId}, using defaults`);
|
|
103
|
+
return {
|
|
104
|
+
content,
|
|
105
|
+
meta: {
|
|
81
106
|
sessionId,
|
|
82
|
-
status:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
status: "draft",
|
|
108
|
+
createdAt: Date.now(),
|
|
109
|
+
updatedAt: Date.now()
|
|
110
|
+
}
|
|
86
111
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
}
|
|
113
|
+
return { content, meta: metaResult.data };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const err = error;
|
|
116
|
+
if (err.code === "ENOENT") {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (error instanceof SyntaxError) {
|
|
120
|
+
this.logger.error(`Failed to read plan for session ${sessionId}: ${error.message}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
this.logger.error(
|
|
124
|
+
`Failed to read plan for session ${sessionId}: ${err.message ?? String(err)}`
|
|
125
|
+
);
|
|
126
|
+
throw PlanError.storageError("read", sessionId, err);
|
|
101
127
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
fs.readFile(this.getPlanPath(sessionId), 'utf-8'),
|
|
114
|
-
fs.readFile(this.getMetaPath(sessionId), 'utf-8'),
|
|
115
|
-
]);
|
|
116
|
-
const metaParsed = JSON.parse(metaContent);
|
|
117
|
-
const metaResult = PlanMetaSchema.safeParse(metaParsed);
|
|
118
|
-
if (!metaResult.success) {
|
|
119
|
-
this.logger?.warn(`Invalid plan metadata for session ${sessionId}, using defaults`);
|
|
120
|
-
// Return with minimal metadata if parsing fails
|
|
121
|
-
return {
|
|
122
|
-
content,
|
|
123
|
-
meta: {
|
|
124
|
-
sessionId,
|
|
125
|
-
status: 'draft',
|
|
126
|
-
createdAt: Date.now(),
|
|
127
|
-
updatedAt: Date.now(),
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
return { content, meta: metaResult.data };
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
const err = error;
|
|
135
|
-
// ENOENT means file doesn't exist - return null (expected case)
|
|
136
|
-
if (err.code === 'ENOENT') {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
// JSON parse errors (SyntaxError) mean corrupted data - treat as not found
|
|
140
|
-
// but log for debugging
|
|
141
|
-
if (error instanceof SyntaxError) {
|
|
142
|
-
this.logger?.error(`Failed to read plan for session ${sessionId}: ${error.message}`);
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
// For real I/O errors (permission denied, disk issues), throw to surface the issue
|
|
146
|
-
this.logger?.error(`Failed to read plan for session ${sessionId}: ${err.message ?? String(err)}`);
|
|
147
|
-
throw PlanError.storageError('read', sessionId, err);
|
|
148
|
-
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Updates the plan content for the given session
|
|
131
|
+
*
|
|
132
|
+
* @throws PlanError.planNotFound if plan doesn't exist
|
|
133
|
+
* @throws PlanError.storageError on filesystem errors
|
|
134
|
+
*/
|
|
135
|
+
async update(sessionId, content) {
|
|
136
|
+
const existing = await this.read(sessionId);
|
|
137
|
+
if (!existing) {
|
|
138
|
+
throw PlanError.planNotFound(sessionId);
|
|
149
139
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
this.logger?.debug(`Updated plan for session ${sessionId}`);
|
|
174
|
-
return {
|
|
175
|
-
oldContent,
|
|
176
|
-
newContent: content,
|
|
177
|
-
meta: updatedMeta,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
throw PlanError.storageError('update', sessionId, error);
|
|
182
|
-
}
|
|
140
|
+
const oldContent = existing.content;
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const updatedMeta = {
|
|
143
|
+
...existing.meta,
|
|
144
|
+
updatedAt: now
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
await Promise.all([
|
|
148
|
+
fs.writeFile(this.getPlanPath(sessionId), content, "utf-8"),
|
|
149
|
+
fs.writeFile(
|
|
150
|
+
this.getMetaPath(sessionId),
|
|
151
|
+
JSON.stringify(updatedMeta, null, 2),
|
|
152
|
+
"utf-8"
|
|
153
|
+
)
|
|
154
|
+
]);
|
|
155
|
+
this.logger.debug(`Updated plan for session ${sessionId}`);
|
|
156
|
+
return {
|
|
157
|
+
oldContent,
|
|
158
|
+
newContent: content,
|
|
159
|
+
meta: updatedMeta
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw PlanError.storageError("update", sessionId, error);
|
|
183
163
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Updates the plan metadata (status, title)
|
|
167
|
+
*
|
|
168
|
+
* @throws PlanError.planNotFound if plan doesn't exist
|
|
169
|
+
* @throws PlanError.storageError on filesystem errors
|
|
170
|
+
*/
|
|
171
|
+
async updateMeta(sessionId, updates) {
|
|
172
|
+
const existing = await this.read(sessionId);
|
|
173
|
+
if (!existing) {
|
|
174
|
+
throw PlanError.planNotFound(sessionId);
|
|
175
|
+
}
|
|
176
|
+
const updatedMeta = {
|
|
177
|
+
...existing.meta,
|
|
178
|
+
...updates,
|
|
179
|
+
updatedAt: Date.now()
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
await fs.writeFile(
|
|
183
|
+
this.getMetaPath(sessionId),
|
|
184
|
+
JSON.stringify(updatedMeta, null, 2),
|
|
185
|
+
"utf-8"
|
|
186
|
+
);
|
|
187
|
+
this.logger.debug(`Updated plan metadata for session ${sessionId}`);
|
|
188
|
+
return updatedMeta;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw PlanError.storageError("update metadata", sessionId, error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Deletes the plan for the given session
|
|
195
|
+
*
|
|
196
|
+
* @throws PlanError.planNotFound if plan doesn't exist
|
|
197
|
+
* @throws PlanError.storageError on filesystem errors
|
|
198
|
+
*/
|
|
199
|
+
async delete(sessionId) {
|
|
200
|
+
if (!await this.exists(sessionId)) {
|
|
201
|
+
throw PlanError.planNotFound(sessionId);
|
|
208
202
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
*/
|
|
215
|
-
async delete(sessionId) {
|
|
216
|
-
if (!(await this.exists(sessionId))) {
|
|
217
|
-
throw PlanError.planNotFound(sessionId);
|
|
218
|
-
}
|
|
219
|
-
try {
|
|
220
|
-
await fs.rm(this.getPlanDir(sessionId), { recursive: true, force: true });
|
|
221
|
-
this.logger?.debug(`Deleted plan for session ${sessionId}`);
|
|
222
|
-
}
|
|
223
|
-
catch (error) {
|
|
224
|
-
throw PlanError.storageError('delete', sessionId, error);
|
|
225
|
-
}
|
|
203
|
+
try {
|
|
204
|
+
await fs.rm(this.getPlanDir(sessionId), { recursive: true, force: true });
|
|
205
|
+
this.logger.debug(`Deleted plan for session ${sessionId}`);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw PlanError.storageError("delete", sessionId, error);
|
|
226
208
|
}
|
|
209
|
+
}
|
|
227
210
|
}
|
|
211
|
+
export {
|
|
212
|
+
PlanService
|
|
213
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var import_vitest = require("vitest");
|
|
25
|
+
var path = __toESM(require("node:path"), 1);
|
|
26
|
+
var fs = __toESM(require("node:fs/promises"), 1);
|
|
27
|
+
var os = __toESM(require("node:os"), 1);
|
|
28
|
+
var import_plan_service = require("./plan-service.js");
|
|
29
|
+
var import_errors = require("./errors.js");
|
|
30
|
+
var import_core = require("@dexto/core");
|
|
31
|
+
const createMockLogger = () => ({
|
|
32
|
+
debug: import_vitest.vi.fn(),
|
|
33
|
+
silly: import_vitest.vi.fn(),
|
|
34
|
+
info: import_vitest.vi.fn(),
|
|
35
|
+
warn: import_vitest.vi.fn(),
|
|
36
|
+
error: import_vitest.vi.fn(),
|
|
37
|
+
trackException: import_vitest.vi.fn(),
|
|
38
|
+
createChild: import_vitest.vi.fn().mockReturnThis(),
|
|
39
|
+
setLevel: import_vitest.vi.fn(),
|
|
40
|
+
getLevel: import_vitest.vi.fn(() => "debug"),
|
|
41
|
+
getLogFilePath: import_vitest.vi.fn(() => null),
|
|
42
|
+
destroy: import_vitest.vi.fn(async () => void 0)
|
|
43
|
+
});
|
|
44
|
+
(0, import_vitest.describe)("PlanService", () => {
|
|
45
|
+
let mockLogger;
|
|
46
|
+
let tempDir;
|
|
47
|
+
let planService;
|
|
48
|
+
(0, import_vitest.beforeEach)(async () => {
|
|
49
|
+
mockLogger = createMockLogger();
|
|
50
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-plan-test-"));
|
|
51
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
52
|
+
planService = new import_plan_service.PlanService({ basePath: tempDir }, mockLogger);
|
|
53
|
+
import_vitest.vi.clearAllMocks();
|
|
54
|
+
});
|
|
55
|
+
(0, import_vitest.afterEach)(async () => {
|
|
56
|
+
try {
|
|
57
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
(0, import_vitest.describe)("exists", () => {
|
|
62
|
+
(0, import_vitest.it)("should return false for non-existent plan", async () => {
|
|
63
|
+
const exists = await planService.exists("non-existent-session");
|
|
64
|
+
(0, import_vitest.expect)(exists).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
(0, import_vitest.it)("should return true for existing plan", async () => {
|
|
67
|
+
const sessionId = "test-session";
|
|
68
|
+
await planService.create(sessionId, "# Test Plan");
|
|
69
|
+
const exists = await planService.exists(sessionId);
|
|
70
|
+
(0, import_vitest.expect)(exists).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
(0, import_vitest.describe)("create", () => {
|
|
74
|
+
(0, import_vitest.it)("should create a new plan with content and metadata", async () => {
|
|
75
|
+
const sessionId = "test-session";
|
|
76
|
+
const content = "# Implementation Plan\n\n## Steps\n1. First step";
|
|
77
|
+
const title = "Test Plan";
|
|
78
|
+
const plan = await planService.create(sessionId, content, { title });
|
|
79
|
+
(0, import_vitest.expect)(plan.content).toBe(content);
|
|
80
|
+
(0, import_vitest.expect)(plan.meta.sessionId).toBe(sessionId);
|
|
81
|
+
(0, import_vitest.expect)(plan.meta.status).toBe("draft");
|
|
82
|
+
(0, import_vitest.expect)(plan.meta.title).toBe(title);
|
|
83
|
+
(0, import_vitest.expect)(plan.meta.createdAt).toBeGreaterThan(0);
|
|
84
|
+
(0, import_vitest.expect)(plan.meta.updatedAt).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
(0, import_vitest.it)("should throw error when plan already exists", async () => {
|
|
87
|
+
const sessionId = "test-session";
|
|
88
|
+
await planService.create(sessionId, "# First Plan");
|
|
89
|
+
try {
|
|
90
|
+
await planService.create(sessionId, "# Second Plan");
|
|
91
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
92
|
+
} catch (error) {
|
|
93
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core.DextoRuntimeError);
|
|
94
|
+
(0, import_vitest.expect)(error.code).toBe(import_errors.PlanErrorCode.PLAN_ALREADY_EXISTS);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
(0, import_vitest.it)("should store plan files on disk", async () => {
|
|
98
|
+
const sessionId = "test-session";
|
|
99
|
+
const content = "# Test Plan";
|
|
100
|
+
await planService.create(sessionId, content);
|
|
101
|
+
const planPath = path.join(tempDir, sessionId, "plan.md");
|
|
102
|
+
const storedContent = await fs.readFile(planPath, "utf-8");
|
|
103
|
+
(0, import_vitest.expect)(storedContent).toBe(content);
|
|
104
|
+
const metaPath = path.join(tempDir, sessionId, "plan-meta.json");
|
|
105
|
+
const metaContent = await fs.readFile(metaPath, "utf-8");
|
|
106
|
+
const meta = JSON.parse(metaContent);
|
|
107
|
+
(0, import_vitest.expect)(meta.sessionId).toBe(sessionId);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
(0, import_vitest.describe)("read", () => {
|
|
111
|
+
(0, import_vitest.it)("should return null for non-existent plan", async () => {
|
|
112
|
+
const plan = await planService.read("non-existent-session");
|
|
113
|
+
(0, import_vitest.expect)(plan).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
(0, import_vitest.it)("should read existing plan with content and metadata", async () => {
|
|
116
|
+
const sessionId = "test-session";
|
|
117
|
+
const content = "# Test Plan";
|
|
118
|
+
const title = "My Plan";
|
|
119
|
+
await planService.create(sessionId, content, { title });
|
|
120
|
+
const plan = await planService.read(sessionId);
|
|
121
|
+
(0, import_vitest.expect)(plan).not.toBeNull();
|
|
122
|
+
(0, import_vitest.expect)(plan.content).toBe(content);
|
|
123
|
+
(0, import_vitest.expect)(plan.meta.sessionId).toBe(sessionId);
|
|
124
|
+
(0, import_vitest.expect)(plan.meta.title).toBe(title);
|
|
125
|
+
});
|
|
126
|
+
(0, import_vitest.it)("should handle invalid metadata schema gracefully", async () => {
|
|
127
|
+
const sessionId = "test-session";
|
|
128
|
+
await planService.create(sessionId, "# Test");
|
|
129
|
+
const metaPath = path.join(tempDir, sessionId, "plan-meta.json");
|
|
130
|
+
await fs.writeFile(metaPath, JSON.stringify({ invalidField: "value" }));
|
|
131
|
+
const plan = await planService.read(sessionId);
|
|
132
|
+
(0, import_vitest.expect)(plan).not.toBeNull();
|
|
133
|
+
(0, import_vitest.expect)(plan.meta.sessionId).toBe(sessionId);
|
|
134
|
+
(0, import_vitest.expect)(plan.meta.status).toBe("draft");
|
|
135
|
+
(0, import_vitest.expect)(mockLogger.warn).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
(0, import_vitest.it)("should return null for corrupted JSON metadata", async () => {
|
|
138
|
+
const sessionId = "test-session";
|
|
139
|
+
await planService.create(sessionId, "# Test");
|
|
140
|
+
const metaPath = path.join(tempDir, sessionId, "plan-meta.json");
|
|
141
|
+
await fs.writeFile(metaPath, "{ invalid json }");
|
|
142
|
+
const plan = await planService.read(sessionId);
|
|
143
|
+
(0, import_vitest.expect)(plan).toBeNull();
|
|
144
|
+
(0, import_vitest.expect)(mockLogger.error).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
(0, import_vitest.describe)("update", () => {
|
|
148
|
+
(0, import_vitest.it)("should update plan content", async () => {
|
|
149
|
+
const sessionId = "test-session";
|
|
150
|
+
await planService.create(sessionId, "# Original Content");
|
|
151
|
+
const result = await planService.update(sessionId, "# Updated Content");
|
|
152
|
+
(0, import_vitest.expect)(result.oldContent).toBe("# Original Content");
|
|
153
|
+
(0, import_vitest.expect)(result.newContent).toBe("# Updated Content");
|
|
154
|
+
(0, import_vitest.expect)(result.meta.updatedAt).toBeGreaterThan(0);
|
|
155
|
+
});
|
|
156
|
+
(0, import_vitest.it)("should preserve metadata when updating content", async () => {
|
|
157
|
+
const sessionId = "test-session";
|
|
158
|
+
const plan = await planService.create(sessionId, "# Original", { title: "My Title" });
|
|
159
|
+
const originalCreatedAt = plan.meta.createdAt;
|
|
160
|
+
await planService.update(sessionId, "# Updated");
|
|
161
|
+
const updatedPlan = await planService.read(sessionId);
|
|
162
|
+
(0, import_vitest.expect)(updatedPlan.meta.title).toBe("My Title");
|
|
163
|
+
(0, import_vitest.expect)(updatedPlan.meta.createdAt).toBe(originalCreatedAt);
|
|
164
|
+
});
|
|
165
|
+
(0, import_vitest.it)("should throw error when plan does not exist", async () => {
|
|
166
|
+
try {
|
|
167
|
+
await planService.update("non-existent", "# Content");
|
|
168
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core.DextoRuntimeError);
|
|
171
|
+
(0, import_vitest.expect)(error.code).toBe(import_errors.PlanErrorCode.PLAN_NOT_FOUND);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
(0, import_vitest.describe)("updateMeta", () => {
|
|
176
|
+
(0, import_vitest.it)("should update plan status", async () => {
|
|
177
|
+
const sessionId = "test-session";
|
|
178
|
+
await planService.create(sessionId, "# Plan");
|
|
179
|
+
const meta = await planService.updateMeta(sessionId, { status: "approved" });
|
|
180
|
+
(0, import_vitest.expect)(meta.status).toBe("approved");
|
|
181
|
+
});
|
|
182
|
+
(0, import_vitest.it)("should update plan title", async () => {
|
|
183
|
+
const sessionId = "test-session";
|
|
184
|
+
await planService.create(sessionId, "# Plan");
|
|
185
|
+
const meta = await planService.updateMeta(sessionId, { title: "New Title" });
|
|
186
|
+
(0, import_vitest.expect)(meta.title).toBe("New Title");
|
|
187
|
+
});
|
|
188
|
+
(0, import_vitest.it)("should throw error when plan does not exist", async () => {
|
|
189
|
+
try {
|
|
190
|
+
await planService.updateMeta("non-existent", { status: "approved" });
|
|
191
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core.DextoRuntimeError);
|
|
194
|
+
(0, import_vitest.expect)(error.code).toBe(import_errors.PlanErrorCode.PLAN_NOT_FOUND);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
(0, import_vitest.describe)("delete", () => {
|
|
199
|
+
(0, import_vitest.it)("should delete existing plan", async () => {
|
|
200
|
+
const sessionId = "test-session";
|
|
201
|
+
await planService.create(sessionId, "# Plan");
|
|
202
|
+
await planService.delete(sessionId);
|
|
203
|
+
const exists = await planService.exists(sessionId);
|
|
204
|
+
(0, import_vitest.expect)(exists).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
(0, import_vitest.it)("should throw error when plan does not exist", async () => {
|
|
207
|
+
try {
|
|
208
|
+
await planService.delete("non-existent");
|
|
209
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
210
|
+
} catch (error) {
|
|
211
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core.DextoRuntimeError);
|
|
212
|
+
(0, import_vitest.expect)(error.code).toBe(import_errors.PlanErrorCode.PLAN_NOT_FOUND);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
(0, import_vitest.it)("should remove plan directory from disk", async () => {
|
|
216
|
+
const sessionId = "test-session";
|
|
217
|
+
await planService.create(sessionId, "# Plan");
|
|
218
|
+
const planDir = path.join(tempDir, sessionId);
|
|
219
|
+
await planService.delete(sessionId);
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(planDir);
|
|
222
|
+
import_vitest.expect.fail("Directory should not exist");
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|