@mytegroupinc/myte-core 0.0.49 → 0.0.50
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 +62 -14
- package/cli.js +211 -51
- package/package.json +2 -2
- package/scripts/feedback-live-full-harness.js +432 -26
- package/scripts/project-assistant-read-certification.js +213 -1
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
const fs = require("node:fs");
|
|
5
|
-
const os = require("node:os");
|
|
6
|
-
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
7
|
const { spawnSync } = require("node:child_process");
|
|
8
8
|
const {
|
|
9
9
|
createCertificationManifest,
|
|
10
10
|
recordCertificationArtifact,
|
|
11
11
|
validateCertificationManifest,
|
|
12
12
|
} = require("../lib/certification-manifest");
|
|
13
|
+
const { readYamlFile } = require("../cli");
|
|
13
14
|
|
|
14
15
|
const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
|
|
15
16
|
const LIVE_SCENARIO_MATRIX = [
|
|
@@ -20,6 +21,15 @@ const LIVE_SCENARIO_MATRIX = [
|
|
|
20
21
|
{ id: "feedback-sync", kind: "read/local-sync", mutation: "local_temp_only", required: true },
|
|
21
22
|
{ id: "feedback-get-history", kind: "read", mutation: "none", required: true },
|
|
22
23
|
{ id: "feedback-comment", kind: "write", mutation: "test_comment", required: true },
|
|
24
|
+
{ id: "feedback-comment-sync-roundtrip", kind: "read/local-sync", mutation: "local_temp_only", required: true },
|
|
25
|
+
{
|
|
26
|
+
id: "feedback-comment-attachment-sync",
|
|
27
|
+
kind: "web-write/read/local-sync",
|
|
28
|
+
mutation: "test_comment_attachment",
|
|
29
|
+
required: "when --require-comment-attachment-sync is set",
|
|
30
|
+
},
|
|
31
|
+
{ id: "feedback-stale-edit-guard", kind: "conflict-safety", mutation: "test_feedback", required: true },
|
|
32
|
+
{ id: "feedback-forced-stale-override", kind: "owner-conflict-override", mutation: "test_feedback", required: true },
|
|
23
33
|
{ id: "feedback-move-undo", kind: "governed-write", mutation: "test_feedback", required: true },
|
|
24
34
|
{ id: "feedback-validate-submit", kind: "review", mutation: "test_review", required: true },
|
|
25
35
|
{ id: "feedback-review-list-detail", kind: "read", mutation: "none", required: true },
|
|
@@ -74,6 +84,10 @@ function requireConfirm(args) {
|
|
|
74
84
|
|
|
75
85
|
function runCli(cliArgs, cwd, { allowFailure = false, apiKey } = {}) {
|
|
76
86
|
const env = { ...process.env };
|
|
87
|
+
delete env.MYTE_OWNER_API_KEY;
|
|
88
|
+
delete env.MYTE_DELEGATE_API_KEY;
|
|
89
|
+
delete env.MYTE_COLLABORATOR_API_KEY;
|
|
90
|
+
delete env.MYTE_WEB_ACCESS_TOKEN;
|
|
77
91
|
if (apiKey) {
|
|
78
92
|
env.MYTE_API_KEY = apiKey;
|
|
79
93
|
env.MYTE_PROJECT_API_KEY = apiKey;
|
|
@@ -99,8 +113,75 @@ function runCli(cliArgs, cwd, { allowFailure = false, apiKey } = {}) {
|
|
|
99
113
|
result.stderr || stdout || "(no output)",
|
|
100
114
|
].join("\n"));
|
|
101
115
|
}
|
|
102
|
-
return { status: result.status, stdout: parsed, stderr: String(result.stderr || "").trim() };
|
|
103
|
-
}
|
|
116
|
+
return { status: result.status, stdout: parsed, stderr: String(result.stderr || "").trim() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveHarnessApiBase(args) {
|
|
120
|
+
const raw = String(
|
|
121
|
+
args["base-url"]
|
|
122
|
+
|| process.env.MYTE_API_BASE
|
|
123
|
+
|| "https://api.myte.dev",
|
|
124
|
+
).trim().replace(/\/+$/, "");
|
|
125
|
+
return raw.endsWith("/api") ? raw : `${raw}/api`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function createWebFeedbackCommentWithAttachment({
|
|
129
|
+
apiBase,
|
|
130
|
+
accessToken,
|
|
131
|
+
feedbackId,
|
|
132
|
+
projectId,
|
|
133
|
+
namespace,
|
|
134
|
+
timeoutMs,
|
|
135
|
+
}) {
|
|
136
|
+
const attachmentName = `${namespace}-comment-context.md`;
|
|
137
|
+
const attachmentContent = [
|
|
138
|
+
`# ${namespace} Comment Attachment`,
|
|
139
|
+
"",
|
|
140
|
+
"This disposable document proves that a web-created Feedback comment attachment",
|
|
141
|
+
"remains associated with its comment and becomes readable after Project Assistant sync.",
|
|
142
|
+
"",
|
|
143
|
+
].join("\n");
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
146
|
+
let response;
|
|
147
|
+
let body;
|
|
148
|
+
try {
|
|
149
|
+
response = await fetch(`${apiBase}/feedbacks/${feedbackId}/comments`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${accessToken}`,
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
project_id: projectId,
|
|
157
|
+
content: `${namespace} web attachment sync certification comment.`,
|
|
158
|
+
attachments: [
|
|
159
|
+
{
|
|
160
|
+
name: attachmentName,
|
|
161
|
+
data: `data:text/markdown;base64,${Buffer.from(attachmentContent, "utf8").toString("base64")}`,
|
|
162
|
+
size: Buffer.byteLength(attachmentContent, "utf8"),
|
|
163
|
+
type: "text/markdown",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}),
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
});
|
|
169
|
+
body = await response.json();
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
}
|
|
173
|
+
if (!response.ok || body?.status !== "success" || !body?.data?.comment_id) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Web Feedback comment attachment fixture failed (${response.status}): ${body?.message || "unknown response"}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
commentId: String(body.data.comment_id),
|
|
180
|
+
commentContent: String(body.data.content || ""),
|
|
181
|
+
attachmentName,
|
|
182
|
+
attachmentContent,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
104
185
|
|
|
105
186
|
function assert(condition, message) {
|
|
106
187
|
if (!condition) {
|
|
@@ -221,22 +302,23 @@ function writeCertificationManifest(manifestPath, manifest) {
|
|
|
221
302
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
222
303
|
}
|
|
223
304
|
|
|
224
|
-
function loadFeedbackManifest(workspace) {
|
|
305
|
+
function loadFeedbackManifest(workspace) {
|
|
225
306
|
const manifestPath = path.join(workspace, "MyteCommandCenter", "data", "feedback.yml");
|
|
226
307
|
if (!fs.existsSync(manifestPath)) {
|
|
227
308
|
return { items: [] };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
}
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const parsed = readYamlFile(manifestPath);
|
|
312
|
+
if (parsed && typeof parsed === "object") {
|
|
313
|
+
return {
|
|
314
|
+
...parsed,
|
|
315
|
+
items: Array.isArray(parsed.items) ? parsed.items : [],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
} catch (_err) {
|
|
319
|
+
// Keep a small fallback parser for a partially written legacy snapshot.
|
|
320
|
+
}
|
|
321
|
+
const text = fs.readFileSync(manifestPath, "utf8");
|
|
240
322
|
const items = [];
|
|
241
323
|
let current = null;
|
|
242
324
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -258,8 +340,62 @@ function loadFeedbackManifest(workspace) {
|
|
|
258
340
|
}
|
|
259
341
|
}
|
|
260
342
|
if (current) items.push(current);
|
|
261
|
-
return { items };
|
|
262
|
-
}
|
|
343
|
+
return { items };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function findFeedbackItem(workspace, feedbackId) {
|
|
347
|
+
const manifest = loadFeedbackManifest(workspace);
|
|
348
|
+
return (manifest.items || []).find(
|
|
349
|
+
(item) => String(item?.feedback_id || item?.id || "") === String(feedbackId),
|
|
350
|
+
) || null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function assertSynchronizedComment({
|
|
354
|
+
workspace,
|
|
355
|
+
feedbackId,
|
|
356
|
+
commentId,
|
|
357
|
+
expectedContent,
|
|
358
|
+
expectedAttachment,
|
|
359
|
+
}) {
|
|
360
|
+
const item = findFeedbackItem(workspace, feedbackId);
|
|
361
|
+
assert(item, `Feedback ${feedbackId} was missing while validating synchronized comments.`);
|
|
362
|
+
const turn = (item.conversation_turns || []).find(
|
|
363
|
+
(candidate) => String(candidate?.comment_id || "") === String(commentId),
|
|
364
|
+
);
|
|
365
|
+
assert(turn, `Comment ${commentId} was not linked to Feedback ${feedbackId} after sync.`);
|
|
366
|
+
assert(
|
|
367
|
+
String(turn.content || "").includes(expectedContent),
|
|
368
|
+
`Comment ${commentId} did not preserve its expected content after sync.`,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
if (!expectedAttachment) return;
|
|
372
|
+
const attachment = (turn.attachments || []).find(
|
|
373
|
+
(candidate) => String(candidate?.name || "") === expectedAttachment.name,
|
|
374
|
+
);
|
|
375
|
+
assert(
|
|
376
|
+
attachment,
|
|
377
|
+
`Comment ${commentId} did not preserve attachment ${expectedAttachment.name}.`,
|
|
378
|
+
);
|
|
379
|
+
assert(
|
|
380
|
+
attachment.context_status === "readable",
|
|
381
|
+
`Comment attachment ${expectedAttachment.name} was not synchronized as readable context.`,
|
|
382
|
+
);
|
|
383
|
+
assert(
|
|
384
|
+
attachment.local_file,
|
|
385
|
+
`Comment attachment ${expectedAttachment.name} did not receive a local file reference.`,
|
|
386
|
+
);
|
|
387
|
+
const commandCenterRoot = path.resolve(workspace, "MyteCommandCenter");
|
|
388
|
+
const localPath = path.resolve(commandCenterRoot, String(attachment.local_file));
|
|
389
|
+
assert(
|
|
390
|
+
localPath.startsWith(`${commandCenterRoot}${path.sep}`),
|
|
391
|
+
`Comment attachment local path escaped MyteCommandCenter: ${attachment.local_file}`,
|
|
392
|
+
);
|
|
393
|
+
assert(fs.existsSync(localPath), `Synchronized comment attachment file is missing: ${localPath}`);
|
|
394
|
+
assert(
|
|
395
|
+
fs.readFileSync(localPath, "utf8").includes(expectedAttachment.contentMarker),
|
|
396
|
+
`Synchronized comment attachment ${expectedAttachment.name} lost its readable content.`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
263
399
|
|
|
264
400
|
function findFeedbackState(workspace, feedbackId) {
|
|
265
401
|
const manifest = loadFeedbackManifest(workspace);
|
|
@@ -294,7 +430,7 @@ function assertFeedbackAbsent(workspace, feedbackIds) {
|
|
|
294
430
|
}
|
|
295
431
|
}
|
|
296
432
|
|
|
297
|
-
function main() {
|
|
433
|
+
async function main() {
|
|
298
434
|
const args = parseArgs(process.argv.slice(2));
|
|
299
435
|
if (!args["confirm-live"]) {
|
|
300
436
|
console.log(JSON.stringify({
|
|
@@ -303,11 +439,13 @@ function main() {
|
|
|
303
439
|
message: "No network, project mutation, or local certification artifact was created.",
|
|
304
440
|
required_live_flag: "--confirm-live",
|
|
305
441
|
optional_strict_role_flag: "--require-role-matrix",
|
|
442
|
+
optional_comment_attachment_flag: "--require-comment-attachment-sync",
|
|
306
443
|
required_role_environment: [
|
|
307
444
|
"MYTE_OWNER_API_KEY",
|
|
308
445
|
"MYTE_DELEGATE_API_KEY",
|
|
309
446
|
"MYTE_COLLABORATOR_API_KEY",
|
|
310
447
|
],
|
|
448
|
+
comment_attachment_environment: "MYTE_WEB_ACCESS_TOKEN",
|
|
311
449
|
scenario_matrix: LIVE_SCENARIO_MATRIX,
|
|
312
450
|
}, null, 2));
|
|
313
451
|
return;
|
|
@@ -322,6 +460,7 @@ function main() {
|
|
|
322
460
|
).trim();
|
|
323
461
|
const delegateApiKey = String(process.env.MYTE_DELEGATE_API_KEY || "").trim();
|
|
324
462
|
const collaboratorApiKey = String(process.env.MYTE_COLLABORATOR_API_KEY || "").trim();
|
|
463
|
+
const webAccessToken = String(process.env.MYTE_WEB_ACCESS_TOKEN || "").trim();
|
|
325
464
|
process.env.MYTE_API_KEY = ownerApiKey;
|
|
326
465
|
process.env.MYTE_PROJECT_API_KEY = ownerApiKey;
|
|
327
466
|
if (
|
|
@@ -332,6 +471,11 @@ function main() {
|
|
|
332
471
|
"--require-role-matrix requires MYTE_OWNER_API_KEY, MYTE_DELEGATE_API_KEY, and MYTE_COLLABORATOR_API_KEY.",
|
|
333
472
|
);
|
|
334
473
|
}
|
|
474
|
+
if (args["require-comment-attachment-sync"] && !webAccessToken) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
"--require-comment-attachment-sync requires an ephemeral MYTE_WEB_ACCESS_TOKEN for a user assigned to the target project.",
|
|
477
|
+
);
|
|
478
|
+
}
|
|
335
479
|
|
|
336
480
|
const workspace = path.resolve(
|
|
337
481
|
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-feedback-full-harness-")),
|
|
@@ -359,8 +503,13 @@ function main() {
|
|
|
359
503
|
collaborator: Boolean(collaboratorApiKey),
|
|
360
504
|
same_project: true,
|
|
361
505
|
collaborator_review_denied: null,
|
|
506
|
+
collaborator_direct_apply_denied: null,
|
|
507
|
+
collaborator_archive_denied: null,
|
|
508
|
+
collaborator_writes_used: false,
|
|
509
|
+
owner_review_used: false,
|
|
362
510
|
delegate_review_used: false,
|
|
363
511
|
};
|
|
512
|
+
let commentAttachmentSyncVerified = false;
|
|
364
513
|
const manifestPath = path.resolve(
|
|
365
514
|
args.manifest
|
|
366
515
|
|| path.join(workspace, "MyteCommandCenter", "certification", `${runId}.json`),
|
|
@@ -507,6 +656,7 @@ function main() {
|
|
|
507
656
|
|
|
508
657
|
const collaboratorWriteKey = collaboratorApiKey || ownerApiKey;
|
|
509
658
|
const operationalReviewKey = delegateApiKey || ownerApiKey;
|
|
659
|
+
roleMatrix.collaborator_writes_used = Boolean(collaboratorApiKey);
|
|
510
660
|
const commentApprovalPath = writeCommentApproval(
|
|
511
661
|
workspace,
|
|
512
662
|
runId,
|
|
@@ -535,6 +685,204 @@ function main() {
|
|
|
535
685
|
cleanup: "retained_with_archived_parent",
|
|
536
686
|
},
|
|
537
687
|
});
|
|
688
|
+
let webAttachmentFixture = null;
|
|
689
|
+
if (webAccessToken) {
|
|
690
|
+
webAttachmentFixture = await createWebFeedbackCommentWithAttachment({
|
|
691
|
+
apiBase: resolveHarnessApiBase(args),
|
|
692
|
+
accessToken: webAccessToken,
|
|
693
|
+
feedbackId: createdFeedbackIds[0],
|
|
694
|
+
projectId: manifest.project_id,
|
|
695
|
+
namespace,
|
|
696
|
+
timeoutMs: Number(args["timeout-ms"] || 60000),
|
|
697
|
+
});
|
|
698
|
+
recordCertificationArtifact(manifest, {
|
|
699
|
+
kind: "comment",
|
|
700
|
+
id: webAttachmentFixture.commentId,
|
|
701
|
+
metadata: {
|
|
702
|
+
feedback_id: createdFeedbackIds[0],
|
|
703
|
+
attachment_name: webAttachmentFixture.attachmentName,
|
|
704
|
+
cleanup: "retained_with_archived_parent",
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
710
|
+
assertSynchronizedComment({
|
|
711
|
+
workspace,
|
|
712
|
+
feedbackId: createdFeedbackIds[0],
|
|
713
|
+
commentId: String(comment.comment_id),
|
|
714
|
+
expectedContent: runId,
|
|
715
|
+
});
|
|
716
|
+
if (webAttachmentFixture) {
|
|
717
|
+
assertSynchronizedComment({
|
|
718
|
+
workspace,
|
|
719
|
+
feedbackId: createdFeedbackIds[0],
|
|
720
|
+
commentId: webAttachmentFixture.commentId,
|
|
721
|
+
expectedContent: namespace,
|
|
722
|
+
expectedAttachment: {
|
|
723
|
+
name: webAttachmentFixture.attachmentName,
|
|
724
|
+
contentMarker: `${namespace} Comment Attachment`,
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
commentAttachmentSyncVerified = true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const staleGuardFeedbackId = createdFeedbackIds[4];
|
|
731
|
+
const staleDraft = runCli([
|
|
732
|
+
"feedback",
|
|
733
|
+
"edit",
|
|
734
|
+
"--feedback-id",
|
|
735
|
+
staleGuardFeedbackId,
|
|
736
|
+
"--title",
|
|
737
|
+
`${namespace} stale local proposal`,
|
|
738
|
+
"--reason",
|
|
739
|
+
"Harness creates this artifact before a remote revision.",
|
|
740
|
+
"--json",
|
|
741
|
+
...baseArgs,
|
|
742
|
+
], workspace).stdout;
|
|
743
|
+
assert(staleDraft.artifact_path, "Stale-edit guard did not create its initial local artifact.");
|
|
744
|
+
|
|
745
|
+
const staleRemoteMove = runCli([
|
|
746
|
+
"feedback",
|
|
747
|
+
"move",
|
|
748
|
+
"--feedback-id",
|
|
749
|
+
staleGuardFeedbackId,
|
|
750
|
+
"--from-state",
|
|
751
|
+
"todo",
|
|
752
|
+
"--to-state",
|
|
753
|
+
"in_progress",
|
|
754
|
+
"--reason",
|
|
755
|
+
"Harness advances the server revision after the local artifact was created.",
|
|
756
|
+
"--no-sync",
|
|
757
|
+
"--json",
|
|
758
|
+
...baseArgs,
|
|
759
|
+
], workspace, { apiKey: collaboratorWriteKey }).stdout;
|
|
760
|
+
const staleRemoteMoveEventId = responseEventId(staleRemoteMove);
|
|
761
|
+
assert(staleRemoteMoveEventId, "Stale-edit guard move did not return an event id.");
|
|
762
|
+
|
|
763
|
+
const staleValidation = runCli([
|
|
764
|
+
"feedback",
|
|
765
|
+
"validate",
|
|
766
|
+
"--file",
|
|
767
|
+
staleDraft.artifact_path,
|
|
768
|
+
"--json",
|
|
769
|
+
...baseArgs,
|
|
770
|
+
], workspace, { apiKey: collaboratorWriteKey }).stdout;
|
|
771
|
+
assert(staleValidation.ok === true && staleValidation.valid === false, "A stale local Feedback artifact unexpectedly validated.");
|
|
772
|
+
assert(
|
|
773
|
+
(staleValidation.errors || []).some((error) => error?.code === "snapshot_mismatch"),
|
|
774
|
+
`Stale artifact did not return snapshot_mismatch: ${JSON.stringify(staleValidation, null, 2)}`,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const staleGuardUndo = runCli([
|
|
778
|
+
"feedback",
|
|
779
|
+
"undo",
|
|
780
|
+
"--feedback-id",
|
|
781
|
+
staleGuardFeedbackId,
|
|
782
|
+
"--event-id",
|
|
783
|
+
staleRemoteMoveEventId,
|
|
784
|
+
"--reason",
|
|
785
|
+
"Harness restores the disposable card after stale-artifact certification.",
|
|
786
|
+
"--no-sync",
|
|
787
|
+
"--json",
|
|
788
|
+
...baseArgs,
|
|
789
|
+
], workspace, { apiKey: collaboratorWriteKey }).stdout;
|
|
790
|
+
assert(
|
|
791
|
+
String(staleGuardUndo.feedback_state || "") === "todo",
|
|
792
|
+
`Stale-edit guard cleanup did not restore todo: ${JSON.stringify(staleGuardUndo, null, 2)}`,
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const forcedStaleApply = runCli([
|
|
796
|
+
"feedback",
|
|
797
|
+
"apply",
|
|
798
|
+
"--file",
|
|
799
|
+
staleDraft.artifact_path,
|
|
800
|
+
"--force",
|
|
801
|
+
"--no-sync",
|
|
802
|
+
"--json",
|
|
803
|
+
...baseArgs,
|
|
804
|
+
], workspace, { apiKey: ownerApiKey }).stdout;
|
|
805
|
+
assert(
|
|
806
|
+
String(forcedStaleApply.feedback?.title || "") === `${namespace} stale proposal`,
|
|
807
|
+
`Owner forced stale override did not apply the intended title: ${JSON.stringify(forcedStaleApply, null, 2)}`,
|
|
808
|
+
);
|
|
809
|
+
assert(
|
|
810
|
+
(forcedStaleApply.warnings || []).some(
|
|
811
|
+
(warning) => warning?.code === "snapshot_mismatch_forced",
|
|
812
|
+
),
|
|
813
|
+
`Owner forced stale override did not report snapshot_mismatch_forced: ${JSON.stringify(forcedStaleApply, null, 2)}`,
|
|
814
|
+
);
|
|
815
|
+
assert(
|
|
816
|
+
forcedStaleApply.history?.force === true
|
|
817
|
+
&& Boolean(String(forcedStaleApply.history?.reason || "").trim()),
|
|
818
|
+
`Owner forced stale override was not explicitly audited with a reason: ${JSON.stringify(forcedStaleApply, null, 2)}`,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
822
|
+
const freshDraft = runCli([
|
|
823
|
+
"feedback",
|
|
824
|
+
"edit",
|
|
825
|
+
"--feedback-id",
|
|
826
|
+
staleGuardFeedbackId,
|
|
827
|
+
"--title",
|
|
828
|
+
`${namespace} fresh proposal after resync`,
|
|
829
|
+
"--reason",
|
|
830
|
+
"Harness confirms a fresh artifact can replace the rejected stale proposal.",
|
|
831
|
+
"--json",
|
|
832
|
+
...baseArgs,
|
|
833
|
+
], workspace).stdout;
|
|
834
|
+
const freshValidation = runCli([
|
|
835
|
+
"feedback",
|
|
836
|
+
"validate",
|
|
837
|
+
"--file",
|
|
838
|
+
freshDraft.artifact_path,
|
|
839
|
+
"--json",
|
|
840
|
+
...baseArgs,
|
|
841
|
+
], workspace, { apiKey: collaboratorWriteKey }).stdout;
|
|
842
|
+
assert(
|
|
843
|
+
freshValidation.ok === true && freshValidation.valid !== false,
|
|
844
|
+
`Fresh artifact did not validate after resync: ${JSON.stringify(freshValidation, null, 2)}`,
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (collaboratorApiKey) {
|
|
848
|
+
const directApplyDenied = runCli([
|
|
849
|
+
"feedback",
|
|
850
|
+
"apply",
|
|
851
|
+
"--file",
|
|
852
|
+
freshDraft.artifact_path,
|
|
853
|
+
"--no-sync",
|
|
854
|
+
"--json",
|
|
855
|
+
...baseArgs,
|
|
856
|
+
], workspace, { allowFailure: true, apiKey: collaboratorApiKey }).stdout;
|
|
857
|
+
roleMatrix.collaborator_direct_apply_denied =
|
|
858
|
+
directApplyDenied.ok === false && directApplyDenied.status === 403;
|
|
859
|
+
assert(
|
|
860
|
+
roleMatrix.collaborator_direct_apply_denied,
|
|
861
|
+
`Regular collaborator direct apply was not denied: ${JSON.stringify(directApplyDenied, null, 2)}`,
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
const collaboratorArchiveDenied = runCli([
|
|
865
|
+
"feedback",
|
|
866
|
+
"move",
|
|
867
|
+
"--feedback-id",
|
|
868
|
+
staleGuardFeedbackId,
|
|
869
|
+
"--from-state",
|
|
870
|
+
"todo",
|
|
871
|
+
"--to-state",
|
|
872
|
+
"archived",
|
|
873
|
+
"--reason",
|
|
874
|
+
"Harness expects regular collaborator archive to be denied.",
|
|
875
|
+
"--no-sync",
|
|
876
|
+
"--json",
|
|
877
|
+
...baseArgs,
|
|
878
|
+
], workspace, { allowFailure: true, apiKey: collaboratorApiKey }).stdout;
|
|
879
|
+
roleMatrix.collaborator_archive_denied =
|
|
880
|
+
collaboratorArchiveDenied.ok === false && collaboratorArchiveDenied.status === 403;
|
|
881
|
+
assert(
|
|
882
|
+
roleMatrix.collaborator_archive_denied,
|
|
883
|
+
`Regular collaborator archive was not denied: ${JSON.stringify(collaboratorArchiveDenied, null, 2)}`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
538
886
|
|
|
539
887
|
const directMove = runCli([
|
|
540
888
|
"feedback",
|
|
@@ -819,11 +1167,38 @@ function main() {
|
|
|
819
1167
|
assert(blocked.status === 409, `Expected 409 pending review block, got ${blocked.status}.`);
|
|
820
1168
|
assert(blocked.data?.code === "pending_feedback_review", `Expected pending_feedback_review, got ${JSON.stringify(blocked, null, 2)}.`);
|
|
821
1169
|
|
|
1170
|
+
const ownerApproval = runCli([
|
|
1171
|
+
"feedback",
|
|
1172
|
+
"review",
|
|
1173
|
+
"--request-id",
|
|
1174
|
+
reviewRequestIds[0],
|
|
1175
|
+
"--action",
|
|
1176
|
+
"approve",
|
|
1177
|
+
"--reason",
|
|
1178
|
+
"Harness owner approval.",
|
|
1179
|
+
"--verification-evidence",
|
|
1180
|
+
"Stale-state, validation, role denial, request-changes, and revision checks passed.",
|
|
1181
|
+
"--deployment-evidence",
|
|
1182
|
+
"Dev post-deploy certification run; artifacts will be archived.",
|
|
1183
|
+
"--no-sync",
|
|
1184
|
+
"--json",
|
|
1185
|
+
...baseArgs,
|
|
1186
|
+
], workspace, { apiKey: ownerApiKey }).stdout;
|
|
1187
|
+
roleMatrix.owner_review_used = true;
|
|
1188
|
+
assert(
|
|
1189
|
+
String(ownerApproval.request?.status || ownerApproval.status || "") === "applied",
|
|
1190
|
+
`Owner approval did not apply: ${JSON.stringify(ownerApproval, null, 2)}`,
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
const batchApprovalRequestIds = [
|
|
1194
|
+
reviewRequestIds[3],
|
|
1195
|
+
reviewRequestIds[4],
|
|
1196
|
+
];
|
|
822
1197
|
const review = runCli([
|
|
823
1198
|
"feedback",
|
|
824
1199
|
"review",
|
|
825
1200
|
"--request-ids",
|
|
826
|
-
|
|
1201
|
+
batchApprovalRequestIds.join(","),
|
|
827
1202
|
"--action",
|
|
828
1203
|
"approve",
|
|
829
1204
|
"--reason",
|
|
@@ -836,7 +1211,7 @@ function main() {
|
|
|
836
1211
|
...baseArgs,
|
|
837
1212
|
], workspace, { apiKey: operationalReviewKey }).stdout;
|
|
838
1213
|
roleMatrix.delegate_review_used = Boolean(delegateApiKey);
|
|
839
|
-
assert(review.ok !== false && review.updated_count ===
|
|
1214
|
+
assert(review.ok !== false && review.updated_count === 2, `batch review failed: ${JSON.stringify(review, null, 2)}`);
|
|
840
1215
|
|
|
841
1216
|
runCli(["feedback-sync", "--json", ...baseArgs], workspace);
|
|
842
1217
|
assertFeedbackPresent(
|
|
@@ -1086,14 +1461,35 @@ function main() {
|
|
|
1086
1461
|
|
|
1087
1462
|
if (roleMatrix.required) {
|
|
1088
1463
|
assert(roleMatrix.same_project, "Certification role keys were not scoped to one project.");
|
|
1464
|
+
assert(roleMatrix.collaborator_writes_used, "Regular collaborator writes were not certified.");
|
|
1089
1465
|
assert(roleMatrix.collaborator_review_denied, "Collaborator approval denial was not certified.");
|
|
1466
|
+
assert(roleMatrix.collaborator_direct_apply_denied, "Collaborator direct-apply denial was not certified.");
|
|
1467
|
+
assert(roleMatrix.collaborator_archive_denied, "Collaborator archive denial was not certified.");
|
|
1468
|
+
assert(roleMatrix.owner_review_used, "Owner approval was not used during strict role certification.");
|
|
1090
1469
|
assert(roleMatrix.delegate_review_used, "Delegate approval was not used during strict role certification.");
|
|
1091
1470
|
}
|
|
1471
|
+
if (args["require-comment-attachment-sync"]) {
|
|
1472
|
+
assert(
|
|
1473
|
+
commentAttachmentSyncVerified,
|
|
1474
|
+
"Web-created Feedback comment attachment did not survive Project Assistant sync.",
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1092
1477
|
manifest.checks = LIVE_SCENARIO_MATRIX.map((scenario) => ({
|
|
1093
1478
|
id: scenario.id,
|
|
1094
|
-
status:
|
|
1095
|
-
|
|
1096
|
-
|
|
1479
|
+
status: scenario.id === "owner-delegate-collaborator-matrix"
|
|
1480
|
+
? (
|
|
1481
|
+
roleMatrix.required
|
|
1482
|
+
? "passed"
|
|
1483
|
+
: delegateApiKey && collaboratorApiKey
|
|
1484
|
+
? "passed_optional"
|
|
1485
|
+
: "optional_not_requested"
|
|
1486
|
+
)
|
|
1487
|
+
: scenario.id === "feedback-comment-attachment-sync"
|
|
1488
|
+
? (
|
|
1489
|
+
commentAttachmentSyncVerified
|
|
1490
|
+
? "passed"
|
|
1491
|
+
: "optional_not_requested"
|
|
1492
|
+
)
|
|
1097
1493
|
: "passed",
|
|
1098
1494
|
checked_at: new Date().toISOString(),
|
|
1099
1495
|
}));
|
|
@@ -1114,6 +1510,10 @@ function main() {
|
|
|
1114
1510
|
review_request_ids: reviewRequestIds,
|
|
1115
1511
|
role_matrix: roleMatrix,
|
|
1116
1512
|
comment_verified: true,
|
|
1513
|
+
comment_sync_roundtrip_verified: true,
|
|
1514
|
+
comment_attachment_sync_verified: commentAttachmentSyncVerified,
|
|
1515
|
+
stale_edit_rejection_and_resync_verified: true,
|
|
1516
|
+
forced_stale_override_verified: true,
|
|
1117
1517
|
move_undo_history_verified: true,
|
|
1118
1518
|
validation_verified: true,
|
|
1119
1519
|
request_changes_revision_verified: true,
|
|
@@ -1180,4 +1580,10 @@ function main() {
|
|
|
1180
1580
|
}
|
|
1181
1581
|
}
|
|
1182
1582
|
|
|
1183
|
-
main()
|
|
1583
|
+
main().catch((err) => {
|
|
1584
|
+
console.error(JSON.stringify({
|
|
1585
|
+
ok: false,
|
|
1586
|
+
message: err?.message || String(err),
|
|
1587
|
+
}, null, 2));
|
|
1588
|
+
process.exitCode = 1;
|
|
1589
|
+
});
|