@mytegroupinc/myte-core 0.0.28 → 0.0.30
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.md +210 -196
- package/cli.js +6631 -6384
- package/lib/ai-gateway.js +115 -115
- package/package.json +28 -28
- package/scripts/feedback-live-full-harness.js +376 -0
- package/scripts/mission-live-disposable-harness.js +226 -226
- package/scripts/mission-live-full-harness.js +832 -832
package/lib/ai-gateway.js
CHANGED
|
@@ -1,115 +1,115 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_MYTEAI_BASE = "https://api.myte.ai/v1";
|
|
4
|
-
|
|
5
|
-
function normalizeMyteAiBase(baseRaw) {
|
|
6
|
-
const baseTrim = String(baseRaw || "").trim().replace(/\/+$/, "");
|
|
7
|
-
const base = baseTrim || DEFAULT_MYTEAI_BASE;
|
|
8
|
-
return /\/v1$/i.test(base) ? base : `${base}/v1`;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function getMyteAiKey(env = process.env) {
|
|
12
|
-
return String(env.MYTEAI_API_KEY || env.MYTE_AI_API_KEY || "").trim();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function buildSimpleAiPayload({ query, jsonResponse = false, maxOutputTokens, temperature }) {
|
|
16
|
-
const payload = {
|
|
17
|
-
messages: [{ role: "user", content: String(query || "").trim() }],
|
|
18
|
-
};
|
|
19
|
-
if (jsonResponse) {
|
|
20
|
-
payload.myte_json_response = true;
|
|
21
|
-
payload.response_format = { type: "json_object" };
|
|
22
|
-
}
|
|
23
|
-
if (Number.isFinite(Number(maxOutputTokens)) && Number(maxOutputTokens) > 0) {
|
|
24
|
-
payload.max_tokens = Number(maxOutputTokens);
|
|
25
|
-
}
|
|
26
|
-
if (temperature !== undefined && temperature !== null && temperature !== "") {
|
|
27
|
-
const parsed = Number(temperature);
|
|
28
|
-
if (Number.isFinite(parsed)) payload.temperature = parsed;
|
|
29
|
-
}
|
|
30
|
-
return payload;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function extractAssistantTextFromChatCompletion(payload) {
|
|
34
|
-
const choices = Array.isArray(payload?.choices) ? payload.choices : [];
|
|
35
|
-
const first = choices[0] && typeof choices[0] === "object" ? choices[0] : {};
|
|
36
|
-
const message = first.message && typeof first.message === "object" ? first.message : {};
|
|
37
|
-
const content = message.content;
|
|
38
|
-
if (typeof content === "string") return content.trim();
|
|
39
|
-
if (Array.isArray(content)) {
|
|
40
|
-
return content
|
|
41
|
-
.map((item) => {
|
|
42
|
-
if (typeof item === "string") return item;
|
|
43
|
-
if (!item || typeof item !== "object") return "";
|
|
44
|
-
if (typeof item.text === "string") return item.text;
|
|
45
|
-
if (typeof item.content === "string") return item.content;
|
|
46
|
-
return "";
|
|
47
|
-
})
|
|
48
|
-
.filter(Boolean)
|
|
49
|
-
.join("\n")
|
|
50
|
-
.trim();
|
|
51
|
-
}
|
|
52
|
-
return "";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function stripJsonFences(text) {
|
|
56
|
-
const raw = String(text || "").trim();
|
|
57
|
-
const match = raw.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
58
|
-
if (match) return String(match[1] || "").trim();
|
|
59
|
-
return raw;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeJsonAssistantText(text) {
|
|
63
|
-
const stripped = stripJsonFences(text);
|
|
64
|
-
const parsed = JSON.parse(stripped);
|
|
65
|
-
return {
|
|
66
|
-
parsed,
|
|
67
|
-
text: JSON.stringify(parsed, null, 2),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function callMyteAiChat({ fetchFn, apiBase, apiKey, payload, timeoutMs }) {
|
|
72
|
-
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
73
|
-
const timeoutId =
|
|
74
|
-
controller && timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
75
|
-
try {
|
|
76
|
-
const response = await fetchFn(`${apiBase}/chat/completions`, {
|
|
77
|
-
method: "POST",
|
|
78
|
-
headers: {
|
|
79
|
-
"Content-Type": "application/json",
|
|
80
|
-
Authorization: `Bearer ${apiKey}`,
|
|
81
|
-
},
|
|
82
|
-
signal: controller?.signal,
|
|
83
|
-
body: JSON.stringify(payload),
|
|
84
|
-
});
|
|
85
|
-
const text = await response.text();
|
|
86
|
-
let body;
|
|
87
|
-
try {
|
|
88
|
-
body = JSON.parse(text);
|
|
89
|
-
} catch (error) {
|
|
90
|
-
const err = new Error(`Non-JSON response (${response.status}): ${text.slice(0, 500)}`);
|
|
91
|
-
err.status = response.status;
|
|
92
|
-
throw err;
|
|
93
|
-
}
|
|
94
|
-
if (!response.ok) {
|
|
95
|
-
const err = new Error(body?.error?.message || body?.message || `Request failed (${response.status})`);
|
|
96
|
-
err.status = response.status;
|
|
97
|
-
err.body = body;
|
|
98
|
-
throw err;
|
|
99
|
-
}
|
|
100
|
-
return body;
|
|
101
|
-
} finally {
|
|
102
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
module.exports = {
|
|
107
|
-
DEFAULT_MYTEAI_BASE,
|
|
108
|
-
buildSimpleAiPayload,
|
|
109
|
-
callMyteAiChat,
|
|
110
|
-
extractAssistantTextFromChatCompletion,
|
|
111
|
-
getMyteAiKey,
|
|
112
|
-
normalizeJsonAssistantText,
|
|
113
|
-
normalizeMyteAiBase,
|
|
114
|
-
stripJsonFences,
|
|
115
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MYTEAI_BASE = "https://api.myte.ai/v1";
|
|
4
|
+
|
|
5
|
+
function normalizeMyteAiBase(baseRaw) {
|
|
6
|
+
const baseTrim = String(baseRaw || "").trim().replace(/\/+$/, "");
|
|
7
|
+
const base = baseTrim || DEFAULT_MYTEAI_BASE;
|
|
8
|
+
return /\/v1$/i.test(base) ? base : `${base}/v1`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getMyteAiKey(env = process.env) {
|
|
12
|
+
return String(env.MYTEAI_API_KEY || env.MYTE_AI_API_KEY || "").trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildSimpleAiPayload({ query, jsonResponse = false, maxOutputTokens, temperature }) {
|
|
16
|
+
const payload = {
|
|
17
|
+
messages: [{ role: "user", content: String(query || "").trim() }],
|
|
18
|
+
};
|
|
19
|
+
if (jsonResponse) {
|
|
20
|
+
payload.myte_json_response = true;
|
|
21
|
+
payload.response_format = { type: "json_object" };
|
|
22
|
+
}
|
|
23
|
+
if (Number.isFinite(Number(maxOutputTokens)) && Number(maxOutputTokens) > 0) {
|
|
24
|
+
payload.max_tokens = Number(maxOutputTokens);
|
|
25
|
+
}
|
|
26
|
+
if (temperature !== undefined && temperature !== null && temperature !== "") {
|
|
27
|
+
const parsed = Number(temperature);
|
|
28
|
+
if (Number.isFinite(parsed)) payload.temperature = parsed;
|
|
29
|
+
}
|
|
30
|
+
return payload;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractAssistantTextFromChatCompletion(payload) {
|
|
34
|
+
const choices = Array.isArray(payload?.choices) ? payload.choices : [];
|
|
35
|
+
const first = choices[0] && typeof choices[0] === "object" ? choices[0] : {};
|
|
36
|
+
const message = first.message && typeof first.message === "object" ? first.message : {};
|
|
37
|
+
const content = message.content;
|
|
38
|
+
if (typeof content === "string") return content.trim();
|
|
39
|
+
if (Array.isArray(content)) {
|
|
40
|
+
return content
|
|
41
|
+
.map((item) => {
|
|
42
|
+
if (typeof item === "string") return item;
|
|
43
|
+
if (!item || typeof item !== "object") return "";
|
|
44
|
+
if (typeof item.text === "string") return item.text;
|
|
45
|
+
if (typeof item.content === "string") return item.content;
|
|
46
|
+
return "";
|
|
47
|
+
})
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join("\n")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripJsonFences(text) {
|
|
56
|
+
const raw = String(text || "").trim();
|
|
57
|
+
const match = raw.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
58
|
+
if (match) return String(match[1] || "").trim();
|
|
59
|
+
return raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeJsonAssistantText(text) {
|
|
63
|
+
const stripped = stripJsonFences(text);
|
|
64
|
+
const parsed = JSON.parse(stripped);
|
|
65
|
+
return {
|
|
66
|
+
parsed,
|
|
67
|
+
text: JSON.stringify(parsed, null, 2),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function callMyteAiChat({ fetchFn, apiBase, apiKey, payload, timeoutMs }) {
|
|
72
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
73
|
+
const timeoutId =
|
|
74
|
+
controller && timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetchFn(`${apiBase}/chat/completions`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
Authorization: `Bearer ${apiKey}`,
|
|
81
|
+
},
|
|
82
|
+
signal: controller?.signal,
|
|
83
|
+
body: JSON.stringify(payload),
|
|
84
|
+
});
|
|
85
|
+
const text = await response.text();
|
|
86
|
+
let body;
|
|
87
|
+
try {
|
|
88
|
+
body = JSON.parse(text);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const err = new Error(`Non-JSON response (${response.status}): ${text.slice(0, 500)}`);
|
|
91
|
+
err.status = response.status;
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const err = new Error(body?.error?.message || body?.message || `Request failed (${response.status})`);
|
|
96
|
+
err.status = response.status;
|
|
97
|
+
err.body = body;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
return body;
|
|
101
|
+
} finally {
|
|
102
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
DEFAULT_MYTEAI_BASE,
|
|
108
|
+
buildSimpleAiPayload,
|
|
109
|
+
callMyteAiChat,
|
|
110
|
+
extractAssistantTextFromChatCompletion,
|
|
111
|
+
getMyteAiKey,
|
|
112
|
+
normalizeJsonAssistantText,
|
|
113
|
+
normalizeMyteAiBase,
|
|
114
|
+
stripJsonFences,
|
|
115
|
+
};
|
package/package.json
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mytegroupinc/myte-core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Myte CLI core implementation.",
|
|
5
|
-
"type": "commonjs",
|
|
6
|
-
"main": "cli.js",
|
|
7
|
-
"files": [
|
|
8
|
-
"README.md",
|
|
9
|
-
"cli.js",
|
|
10
|
-
"lib",
|
|
11
|
-
"scripts",
|
|
12
|
-
"package.json"
|
|
13
|
-
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"test": "node --test"
|
|
16
|
-
},
|
|
17
|
-
"license": "MIT",
|
|
18
|
-
"engines": {
|
|
19
|
-
"node": ">=18"
|
|
20
|
-
},
|
|
21
|
-
"publishConfig": {
|
|
22
|
-
"access": "public"
|
|
23
|
-
},
|
|
24
|
-
"dependencies": {},
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"yaml": "^2.8.1"
|
|
27
|
-
}
|
|
28
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mytegroupinc/myte-core",
|
|
3
|
+
"version": "0.0.30",
|
|
4
|
+
"description": "Myte CLI core implementation.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "cli.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"README.md",
|
|
9
|
+
"cli.js",
|
|
10
|
+
"lib",
|
|
11
|
+
"scripts",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"yaml": "^2.8.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { spawnSync } = require("node:child_process");
|
|
8
|
+
|
|
9
|
+
const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const args = { _: [] };
|
|
13
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
14
|
+
const token = argv[index];
|
|
15
|
+
if (!token.startsWith("--")) {
|
|
16
|
+
args._.push(token);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const key = token.slice(2);
|
|
20
|
+
const next = argv[index + 1];
|
|
21
|
+
if (!next || next.startsWith("--")) {
|
|
22
|
+
args[key] = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
args[key] = next;
|
|
26
|
+
index += 1;
|
|
27
|
+
}
|
|
28
|
+
return args;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireConfirm(args) {
|
|
32
|
+
if (!args["confirm-live"]) {
|
|
33
|
+
throw new Error("Refusing live feedback mutations. Re-run with --confirm-live after backend/web deploy.");
|
|
34
|
+
}
|
|
35
|
+
if (!process.env.MYTE_API_KEY && !process.env.MYTE_PROJECT_API_KEY) {
|
|
36
|
+
throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runCli(cliArgs, cwd, { allowFailure = false } = {}) {
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...cliArgs], {
|
|
42
|
+
cwd,
|
|
43
|
+
env: process.env,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
const stdout = String(result.stdout || "").trim();
|
|
48
|
+
let parsed = {};
|
|
49
|
+
if (stdout) {
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(stdout);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`Expected JSON from myte ${cliArgs.join(" ")}:\n${stdout}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (result.status !== 0 && !allowFailure) {
|
|
57
|
+
throw new Error([
|
|
58
|
+
`Command failed: myte ${cliArgs.join(" ")}`,
|
|
59
|
+
result.stderr || stdout || "(no output)",
|
|
60
|
+
].join("\n"));
|
|
61
|
+
}
|
|
62
|
+
return { status: result.status, stdout: parsed, stderr: String(result.stderr || "").trim() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assert(condition, message) {
|
|
66
|
+
if (!condition) {
|
|
67
|
+
throw new Error(message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writePrd(workspace, title, index) {
|
|
72
|
+
const filePath = path.join(workspace, `feedback-live-harness-${index}.md`);
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
filePath,
|
|
75
|
+
[
|
|
76
|
+
`# ${title}`,
|
|
77
|
+
"",
|
|
78
|
+
"## Goal",
|
|
79
|
+
"Disposable Feedback item created by the reusable live Feedback API harness.",
|
|
80
|
+
"",
|
|
81
|
+
"## Acceptance Criteria",
|
|
82
|
+
"- Feedback can be created from markdown.",
|
|
83
|
+
"- Feedback can enter the review membrane.",
|
|
84
|
+
"- Feedback can be batch-reviewed.",
|
|
85
|
+
"- Feedback can be batch-moved between active states.",
|
|
86
|
+
"- Feedback can be batch-archived and disappears from normal sync.",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
return filePath;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadFeedbackManifest(workspace) {
|
|
94
|
+
const manifestPath = path.join(workspace, "MyteCommandCenter", "data", "feedback.yml");
|
|
95
|
+
if (!fs.existsSync(manifestPath)) {
|
|
96
|
+
return { items: [] };
|
|
97
|
+
}
|
|
98
|
+
const text = fs.readFileSync(manifestPath, "utf8");
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(text);
|
|
101
|
+
const items = []
|
|
102
|
+
.concat(Array.isArray(parsed.items) ? parsed.items : [])
|
|
103
|
+
.concat(Array.isArray(parsed.queue) ? parsed.queue : []);
|
|
104
|
+
return { items };
|
|
105
|
+
} catch (_err) {
|
|
106
|
+
// Older local files may still be YAML-like. Keep a small fallback parser so
|
|
107
|
+
// the harness can validate either shape without adding runtime deps.
|
|
108
|
+
}
|
|
109
|
+
const items = [];
|
|
110
|
+
let current = null;
|
|
111
|
+
for (const line of text.split(/\r?\n/)) {
|
|
112
|
+
const feedbackMatch = line.match(/^\s*-\s+feedback_id:\s*["']?([^"'\s]+)["']?\s*$/) || line.match(/^\s*feedback_id:\s*["']?([^"'\s]+)["']?\s*$/);
|
|
113
|
+
if (feedbackMatch) {
|
|
114
|
+
if (current) items.push(current);
|
|
115
|
+
current = { feedback_id: feedbackMatch[1] };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!current) continue;
|
|
119
|
+
const stateMatch = line.match(/^\s*feedback_state:\s*["']?([^"']+)["']?\s*$/);
|
|
120
|
+
if (stateMatch) {
|
|
121
|
+
current.feedback_state = stateMatch[1].trim();
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const statusMatch = line.match(/^\s*status:\s*["']?([^"']+)["']?\s*$/);
|
|
125
|
+
if (statusMatch) {
|
|
126
|
+
current.status = statusMatch[1].trim();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (current) items.push(current);
|
|
130
|
+
return { items };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findFeedbackState(workspace, feedbackId) {
|
|
134
|
+
const manifest = loadFeedbackManifest(workspace);
|
|
135
|
+
const matches = (manifest.items || []).filter((candidate) => String(candidate.feedback_id || candidate.id || "") === String(feedbackId));
|
|
136
|
+
const item = [...matches].reverse().find((candidate) => candidate.feedback_state) || matches[matches.length - 1];
|
|
137
|
+
if (!item) return null;
|
|
138
|
+
const state = String(item.feedback_state || "").trim();
|
|
139
|
+
if (state) return state;
|
|
140
|
+
const status = String(item.status || "").trim().toLowerCase();
|
|
141
|
+
if (status === "pending") return "todo";
|
|
142
|
+
if (status === "resolved") return "completed";
|
|
143
|
+
if (status === "ignored") return "rejected";
|
|
144
|
+
if (status === "archived") return "archived";
|
|
145
|
+
return status || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertFeedbackPresent(workspace, feedbackIds, expectedState) {
|
|
149
|
+
for (const feedbackId of feedbackIds) {
|
|
150
|
+
const state = findFeedbackState(workspace, feedbackId);
|
|
151
|
+
assert(state, `Expected feedback ${feedbackId} to be present in feedback-sync output.`);
|
|
152
|
+
if (expectedState) {
|
|
153
|
+
assert(state === expectedState, `Expected feedback ${feedbackId} state ${expectedState}, got ${state}.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function assertFeedbackAbsent(workspace, feedbackIds) {
|
|
159
|
+
const manifest = loadFeedbackManifest(workspace);
|
|
160
|
+
const syncedIds = new Set((manifest.items || []).map((item) => String(item.feedback_id || item.id || "")));
|
|
161
|
+
for (const feedbackId of feedbackIds) {
|
|
162
|
+
assert(!syncedIds.has(String(feedbackId)), `Archived feedback ${feedbackId} is still present in normal feedback-sync output.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function main() {
|
|
167
|
+
const args = parseArgs(process.argv.slice(2));
|
|
168
|
+
requireConfirm(args);
|
|
169
|
+
|
|
170
|
+
const workspace = path.resolve(
|
|
171
|
+
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-feedback-full-harness-")),
|
|
172
|
+
);
|
|
173
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
174
|
+
|
|
175
|
+
const baseArgs = [];
|
|
176
|
+
if (args["base-url"]) {
|
|
177
|
+
baseArgs.push("--base-url", String(args["base-url"]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
181
|
+
const reason = String(args.reason || "Disposable live Feedback harness verification");
|
|
182
|
+
const createdFeedbackIds = [];
|
|
183
|
+
const reviewRequestIds = [];
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const prdFiles = [];
|
|
187
|
+
for (let index = 1; index <= 5; index += 1) {
|
|
188
|
+
const title = `TEST Feedback Harness ${stamp} ${index}`;
|
|
189
|
+
prdFiles.push(writePrd(workspace, title, index));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const create = runCli([
|
|
193
|
+
"create-prd",
|
|
194
|
+
...prdFiles,
|
|
195
|
+
"--description",
|
|
196
|
+
"Disposable TEST Feedback created by the live Feedback harness.",
|
|
197
|
+
"--json",
|
|
198
|
+
...baseArgs,
|
|
199
|
+
], workspace).stdout;
|
|
200
|
+
|
|
201
|
+
const createdItems = Array.isArray(create.items) ? create.items : [];
|
|
202
|
+
for (const item of createdItems) {
|
|
203
|
+
if (item.status === "created" && item.feedback_id) {
|
|
204
|
+
createdFeedbackIds.push(String(item.feedback_id));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
assert(createdFeedbackIds.length === 5, `Expected 5 created TEST feedback items, got ${createdFeedbackIds.length}: ${JSON.stringify(create, null, 2)}`);
|
|
208
|
+
|
|
209
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
210
|
+
assertFeedbackPresent(workspace, createdFeedbackIds, "todo");
|
|
211
|
+
|
|
212
|
+
for (const feedbackId of createdFeedbackIds) {
|
|
213
|
+
const draft = runCli([
|
|
214
|
+
"feedback",
|
|
215
|
+
"status",
|
|
216
|
+
"--feedback-id",
|
|
217
|
+
feedbackId,
|
|
218
|
+
"--status",
|
|
219
|
+
"completed",
|
|
220
|
+
"--reason",
|
|
221
|
+
reason,
|
|
222
|
+
"--json",
|
|
223
|
+
...baseArgs,
|
|
224
|
+
], workspace).stdout;
|
|
225
|
+
assert(draft.artifact_path, `feedback status did not return artifact_path for ${feedbackId}`);
|
|
226
|
+
|
|
227
|
+
const submit = runCli([
|
|
228
|
+
"feedback",
|
|
229
|
+
"submit",
|
|
230
|
+
"--file",
|
|
231
|
+
draft.artifact_path,
|
|
232
|
+
"--json",
|
|
233
|
+
...baseArgs,
|
|
234
|
+
], workspace).stdout;
|
|
235
|
+
const requestId = String(submit.request?.request_id || submit.request?._id || "");
|
|
236
|
+
assert(requestId, `feedback submit did not return request id for ${feedbackId}: ${JSON.stringify(submit, null, 2)}`);
|
|
237
|
+
reviewRequestIds.push(requestId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const blocked = runCli([
|
|
241
|
+
"feedback",
|
|
242
|
+
"move",
|
|
243
|
+
"--feedback-ids",
|
|
244
|
+
createdFeedbackIds.join(","),
|
|
245
|
+
"--from-state",
|
|
246
|
+
"todo",
|
|
247
|
+
"--to-state",
|
|
248
|
+
"in_progress",
|
|
249
|
+
"--reason",
|
|
250
|
+
"Harness should be blocked while review is pending.",
|
|
251
|
+
"--no-sync",
|
|
252
|
+
"--json",
|
|
253
|
+
...baseArgs,
|
|
254
|
+
], workspace, { allowFailure: true }).stdout;
|
|
255
|
+
assert(blocked.ok === false, "Batch direct feedback move unexpectedly succeeded while reviews were pending.");
|
|
256
|
+
assert(blocked.status === 409, `Expected 409 pending review block, got ${blocked.status}.`);
|
|
257
|
+
assert(blocked.data?.code === "pending_feedback_review", `Expected pending_feedback_review, got ${JSON.stringify(blocked, null, 2)}.`);
|
|
258
|
+
|
|
259
|
+
const review = runCli([
|
|
260
|
+
"feedback",
|
|
261
|
+
"review",
|
|
262
|
+
"--request-ids",
|
|
263
|
+
reviewRequestIds.join(","),
|
|
264
|
+
"--action",
|
|
265
|
+
"approve",
|
|
266
|
+
"--reason",
|
|
267
|
+
"Harness batch approval.",
|
|
268
|
+
"--json",
|
|
269
|
+
...baseArgs,
|
|
270
|
+
], workspace).stdout;
|
|
271
|
+
assert(review.ok !== false && review.updated_count === 5, `batch review failed: ${JSON.stringify(review, null, 2)}`);
|
|
272
|
+
|
|
273
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
274
|
+
assertFeedbackPresent(workspace, createdFeedbackIds, "completed");
|
|
275
|
+
|
|
276
|
+
const reopen = runCli([
|
|
277
|
+
"feedback",
|
|
278
|
+
"move",
|
|
279
|
+
"--feedback-ids",
|
|
280
|
+
createdFeedbackIds.join(","),
|
|
281
|
+
"--from-state",
|
|
282
|
+
"completed",
|
|
283
|
+
"--to-state",
|
|
284
|
+
"in_progress",
|
|
285
|
+
"--reason",
|
|
286
|
+
"Harness active-state batch reopen.",
|
|
287
|
+
"--json",
|
|
288
|
+
...baseArgs,
|
|
289
|
+
], workspace).stdout;
|
|
290
|
+
assert(reopen.ok !== false && reopen.updated_count === 5, `batch active move failed: ${JSON.stringify(reopen, null, 2)}`);
|
|
291
|
+
|
|
292
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
293
|
+
assertFeedbackPresent(workspace, createdFeedbackIds, "in_progress");
|
|
294
|
+
|
|
295
|
+
const archive = runCli([
|
|
296
|
+
"feedback",
|
|
297
|
+
"move",
|
|
298
|
+
"--feedback-ids",
|
|
299
|
+
createdFeedbackIds.join(","),
|
|
300
|
+
"--from-state",
|
|
301
|
+
"in_progress",
|
|
302
|
+
"--to-state",
|
|
303
|
+
"archived",
|
|
304
|
+
"--reason",
|
|
305
|
+
"Harness cleanup archive.",
|
|
306
|
+
"--json",
|
|
307
|
+
...baseArgs,
|
|
308
|
+
], workspace).stdout;
|
|
309
|
+
assert(archive.ok !== false && archive.updated_count === 5, `batch archive failed: ${JSON.stringify(archive, null, 2)}`);
|
|
310
|
+
|
|
311
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
312
|
+
assertFeedbackAbsent(workspace, createdFeedbackIds);
|
|
313
|
+
|
|
314
|
+
const unarchive = runCli([
|
|
315
|
+
"feedback",
|
|
316
|
+
"move",
|
|
317
|
+
"--feedback-id",
|
|
318
|
+
createdFeedbackIds[0],
|
|
319
|
+
"--from-state",
|
|
320
|
+
"archived",
|
|
321
|
+
"--to-state",
|
|
322
|
+
"todo",
|
|
323
|
+
"--reason",
|
|
324
|
+
"Harness verifies project-key unarchive is blocked.",
|
|
325
|
+
"--no-sync",
|
|
326
|
+
"--json",
|
|
327
|
+
...baseArgs,
|
|
328
|
+
], workspace, { allowFailure: true }).stdout;
|
|
329
|
+
assert(unarchive.ok === false, "Project-key Feedback unarchive unexpectedly succeeded.");
|
|
330
|
+
assert(unarchive.status === 403, `Expected project-key unarchive to return 403, got ${unarchive.status}.`);
|
|
331
|
+
assert(unarchive.data?.code === "feedback_unarchive_not_supported_project_api", `Unexpected unarchive error: ${JSON.stringify(unarchive, null, 2)}`);
|
|
332
|
+
|
|
333
|
+
console.log(JSON.stringify({
|
|
334
|
+
ok: true,
|
|
335
|
+
workspace,
|
|
336
|
+
created_feedback_ids: createdFeedbackIds,
|
|
337
|
+
review_request_ids: reviewRequestIds,
|
|
338
|
+
pending_review_batch_block_verified: true,
|
|
339
|
+
batch_review_verified: true,
|
|
340
|
+
batch_active_move_verified: true,
|
|
341
|
+
batch_archive_verified: true,
|
|
342
|
+
archived_sync_exclusion_verified: true,
|
|
343
|
+
project_key_unarchive_block_verified: true,
|
|
344
|
+
}, null, 2));
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (createdFeedbackIds.length) {
|
|
347
|
+
try {
|
|
348
|
+
runCli([
|
|
349
|
+
"feedback",
|
|
350
|
+
"move",
|
|
351
|
+
"--feedback-ids",
|
|
352
|
+
createdFeedbackIds.join(","),
|
|
353
|
+
"--to-state",
|
|
354
|
+
"archived",
|
|
355
|
+
"--reason",
|
|
356
|
+
"Harness best-effort cleanup after failure.",
|
|
357
|
+
"--json",
|
|
358
|
+
...baseArgs,
|
|
359
|
+
], workspace, { allowFailure: true });
|
|
360
|
+
} catch (_cleanupErr) {
|
|
361
|
+
// Keep the original error. Cleanup is best effort and must not hide failure evidence.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const failure = {
|
|
365
|
+
ok: false,
|
|
366
|
+
workspace,
|
|
367
|
+
created_feedback_ids: createdFeedbackIds,
|
|
368
|
+
review_request_ids: reviewRequestIds,
|
|
369
|
+
message: err?.message || String(err),
|
|
370
|
+
};
|
|
371
|
+
console.error(JSON.stringify(failure, null, 2));
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
main();
|