openplanr 2.2639.4 → 2.2640.0
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/CHANGELOG.md +15 -0
- package/dist/cli/commands/github.d.ts.map +1 -1
- package/dist/cli/commands/github.js +4 -3
- package/dist/cli/commands/github.js.map +1 -1
- package/dist/dashboard/.vite/manifest.json +1 -1
- package/dist/dashboard/assets/{index-p83hrHZD.js → index-9DLb9EO5.js} +2 -2
- package/dist/dashboard/assets/{index-p83hrHZD.js.map → index-9DLb9EO5.js.map} +1 -1
- package/dist/dashboard/dashboard-manifest.json +13 -13
- package/dist/dashboard/index.html +1 -1
- package/dist/models/schema.d.ts.map +1 -1
- package/dist/models/schema.js +24 -1
- package/dist/models/schema.js.map +1 -1
- package/dist/services/artifact-service.js +2 -2
- package/dist/services/artifact-service.js.map +1 -1
- package/dist/services/github-service.d.ts +6 -4
- package/dist/services/github-service.d.ts.map +1 -1
- package/dist/services/github-service.js +7 -2
- package/dist/services/github-service.js.map +1 -1
- package/dist/services/id-service.d.ts +4 -1
- package/dist/services/id-service.d.ts.map +1 -1
- package/dist/services/id-service.js +28 -15
- package/dist/services/id-service.js.map +1 -1
- package/dist/services/landing/receipt-custody.d.ts.map +1 -1
- package/dist/services/landing/receipt-custody.js +2 -0
- package/dist/services/landing/receipt-custody.js.map +1 -1
- package/dist/services/report-linter-service.d.ts.map +1 -1
- package/dist/services/report-linter-service.js +2 -4
- package/dist/services/report-linter-service.js.map +1 -1
- package/docs/CLI.md +2 -0
- package/lib/host-packages/adapter-registry.json +5 -5
- package/lib/host-packages/capability-map.json +1 -1
- package/lib/host-packages/claude/.claude-plugin/marketplace.json +2 -2
- package/lib/host-packages/claude/openplanr/.claude-plugin/icon.svg +11 -0
- package/lib/host-packages/claude/openplanr/.claude-plugin/plugin.json +8 -3
- package/lib/host-packages/claude/openplanr/.openplanr-content.json +160 -28
- package/lib/host-packages/claude/openplanr/README.md +4 -3
- package/lib/host-packages/claude/openplanr/skills/ceo-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/chair-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/challenger-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/cmo-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/coo-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/cpo-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/cto-review/SKILL.md +1 -1
- package/lib/host-packages/claude/openplanr/skills/design/openplanr.skill.json +88 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/design.mjs +83 -21459
- package/lib/host-packages/claude/openplanr/skills/design/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/claude/openplanr/skills/design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/design-loop/openplanr.skill.json +88 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/design.mjs +83 -21459
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/design-review/openplanr.skill.json +88 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/design.mjs +83 -21459
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/plan/openplanr.skill.json +88 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/design.mjs +83 -21459
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/sync/SKILL.md +17 -6
- package/lib/host-packages/claude/openplanr/skills/sync/scripts/sync.mjs +7 -104
- package/lib/host-packages/cursor/openplanr/.openplanr-content.json +144 -16
- package/lib/host-packages/cursor/openplanr/manifest.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/design.mjs +83 -21459
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/design.mjs +83 -21459
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/design.mjs +83 -21459
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/design.mjs +83 -21459
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-sync/scripts/sync.mjs +7 -104
- package/lib/host-packages/cursor/openplanr/rules/planr-sync.mdc +17 -6
- package/lib/host-packages/openai/.claude-plugin/marketplace.json +2 -2
- package/lib/host-packages/openai/openplanr/.codex-plugin/plugin.json +3 -2
- package/lib/host-packages/openai/openplanr/.openplanr-content.json +155 -27
- package/lib/host-packages/openai/openplanr/skills/ceo-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/chair-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/challenger-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/cmo-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/coo-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/cpo-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/cto-review/SKILL.md +1 -1
- package/lib/host-packages/openai/openplanr/skills/design/openplanr.skill.json +88 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/design.mjs +83 -21459
- package/lib/host-packages/openai/openplanr/skills/design/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/openai/openplanr/skills/design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/design-loop/openplanr.skill.json +88 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/design.mjs +83 -21459
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/design-review/openplanr.skill.json +88 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/design.mjs +83 -21459
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/plan/openplanr.skill.json +88 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-artifact-shell.mjs +2506 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-document.mjs +3674 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-entities.mjs +489 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-parse5-parser.mjs +3970 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-parse5-tokenizer.mjs +3501 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-review-handoff.mjs +4408 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-sandbox-guards.mjs +1073 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design-share.mjs +2127 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/design.mjs +83 -21459
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/runtime/packages/design/templates/studio/studio.js +1 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/sync/SKILL.md +17 -6
- package/lib/host-packages/openai/openplanr/skills/sync/scripts/sync.mjs +7 -104
- package/lib/integrations.d.mts +0 -10
- package/lib/integrations.mjs +7 -104
- package/package.json +2 -2
|
@@ -0,0 +1,2127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESIGN_WORKSPACE_API,
|
|
3
|
+
DESIGN_WORKSPACE_CREATE_SCHEMA,
|
|
4
|
+
DESIGN_WORKSPACE_EVENT_SCHEMA,
|
|
5
|
+
DESIGN_WORKSPACE_MAX_BYTES,
|
|
6
|
+
DESIGN_WORKSPACE_MAX_EVENT_BYTES,
|
|
7
|
+
DESIGN_WORKSPACE_REVISION_SCHEMA,
|
|
8
|
+
DESIGN_WORKSPACE_SCHEMA,
|
|
9
|
+
DESIGN_WORKSPACE_VERSION,
|
|
10
|
+
acquireStartLock,
|
|
11
|
+
assertDesignReviewBundle,
|
|
12
|
+
assertDesignReviewMetadata,
|
|
13
|
+
assertWorkspaceContract,
|
|
14
|
+
atomicJson,
|
|
15
|
+
bundleDesignRevision,
|
|
16
|
+
canonicalizeJson,
|
|
17
|
+
configuredPlanrHome,
|
|
18
|
+
currentDesign,
|
|
19
|
+
deepFreeze,
|
|
20
|
+
hash,
|
|
21
|
+
planrHome,
|
|
22
|
+
readJson,
|
|
23
|
+
sha256Hex
|
|
24
|
+
} from "./design-document.mjs";
|
|
25
|
+
import {
|
|
26
|
+
ARTIFACT_ERROR_CODES,
|
|
27
|
+
PipelineError,
|
|
28
|
+
canonicalSerialize,
|
|
29
|
+
digestArtifactEnvelope,
|
|
30
|
+
validateArtifactReview
|
|
31
|
+
} from "./design-artifact-shell.mjs";
|
|
32
|
+
|
|
33
|
+
// packages/design/lib/design/share.mjs
|
|
34
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
35
|
+
import {
|
|
36
|
+
chmodSync,
|
|
37
|
+
closeSync,
|
|
38
|
+
existsSync as existsSync3,
|
|
39
|
+
fsyncSync,
|
|
40
|
+
lstatSync as lstatSync3,
|
|
41
|
+
mkdirSync as mkdirSync3,
|
|
42
|
+
openSync,
|
|
43
|
+
readFileSync as readFileSync3,
|
|
44
|
+
realpathSync as realpathSync2,
|
|
45
|
+
renameSync as renameSync3,
|
|
46
|
+
unlinkSync,
|
|
47
|
+
writeFileSync as writeFileSync3
|
|
48
|
+
} from "node:fs";
|
|
49
|
+
import { homedir as homedir2 } from "node:os";
|
|
50
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2 } from "node:path";
|
|
51
|
+
|
|
52
|
+
// packages/artifact/lib/artifact/import.mjs
|
|
53
|
+
import {
|
|
54
|
+
existsSync as existsSync2,
|
|
55
|
+
lstatSync as lstatSync2,
|
|
56
|
+
mkdirSync as mkdirSync2,
|
|
57
|
+
readFileSync as readFileSync2,
|
|
58
|
+
realpathSync,
|
|
59
|
+
renameSync as renameSync2,
|
|
60
|
+
rmSync as rmSync2,
|
|
61
|
+
statSync as statSync2,
|
|
62
|
+
writeFileSync as writeFileSync2
|
|
63
|
+
} from "node:fs";
|
|
64
|
+
import { homedir } from "node:os";
|
|
65
|
+
import { dirname as dirname2, isAbsolute, join, relative, resolve } from "node:path";
|
|
66
|
+
|
|
67
|
+
// packages/artifact/lib/artifact/internal/feedback.mjs
|
|
68
|
+
var FEEDBACK_FILE = "feedback.json";
|
|
69
|
+
|
|
70
|
+
// packages/artifact/lib/artifact/merge.mjs
|
|
71
|
+
var ARTIFACT_REVIEW_STATE_VERSION = "1.0.0";
|
|
72
|
+
var ARTIFACT_REVIEW_STATE_KIND = "artifact-review-state";
|
|
73
|
+
var SHA256_RE = /^[a-f0-9]{64}$/;
|
|
74
|
+
var ARTIFACT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
75
|
+
function conflict(message, details = void 0) {
|
|
76
|
+
throw new PipelineError(
|
|
77
|
+
ARTIFACT_ERROR_CODES.MERGE_CONFLICT,
|
|
78
|
+
message,
|
|
79
|
+
"Keep both reviews under different stable IDs or resolve the conflicting item explicitly.",
|
|
80
|
+
details
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function assertUniqueReviewItemIds(review) {
|
|
84
|
+
const pinIds = /* @__PURE__ */ new Set();
|
|
85
|
+
for (const pin of review.pins ?? []) {
|
|
86
|
+
if (pinIds.has(pin.id)) {
|
|
87
|
+
throw new PipelineError(
|
|
88
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
89
|
+
"Artifact review contains a duplicate pin ID."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
pinIds.add(pin.id);
|
|
93
|
+
const replyIds = /* @__PURE__ */ new Set();
|
|
94
|
+
for (const reply of pin.replies ?? []) {
|
|
95
|
+
if (replyIds.has(reply.id)) {
|
|
96
|
+
throw new PipelineError(
|
|
97
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
98
|
+
"Artifact review contains a duplicate reply ID in one thread."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
replyIds.add(reply.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return review;
|
|
105
|
+
}
|
|
106
|
+
function clone(value) {
|
|
107
|
+
return structuredClone(value);
|
|
108
|
+
}
|
|
109
|
+
function same(a, b) {
|
|
110
|
+
return canonicalSerialize(a) === canonicalSerialize(b);
|
|
111
|
+
}
|
|
112
|
+
function timeValue(value) {
|
|
113
|
+
if (typeof value !== "string") return Number.NEGATIVE_INFINITY;
|
|
114
|
+
const parsed = Date.parse(value);
|
|
115
|
+
return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
|
|
116
|
+
}
|
|
117
|
+
function compareStable(a, b, idKey) {
|
|
118
|
+
const aTime = a.createdAt ?? a.updatedAt ?? "";
|
|
119
|
+
const bTime = b.createdAt ?? b.updatedAt ?? "";
|
|
120
|
+
return aTime.localeCompare(bTime) || String(a[idKey]).localeCompare(String(b[idKey]));
|
|
121
|
+
}
|
|
122
|
+
function replyMap(replies) {
|
|
123
|
+
const map = /* @__PURE__ */ new Map();
|
|
124
|
+
for (const reply of replies ?? []) {
|
|
125
|
+
const previous = map.get(reply.id);
|
|
126
|
+
if (previous && !same(previous, reply)) {
|
|
127
|
+
conflict("A reply ID refers to different immutable reply content.", { entity: "reply" });
|
|
128
|
+
}
|
|
129
|
+
map.set(reply.id, clone(reply));
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
function mergeReplies(stored = [], incoming = []) {
|
|
134
|
+
const merged = replyMap(stored);
|
|
135
|
+
for (const reply of incoming) {
|
|
136
|
+
const previous = merged.get(reply.id);
|
|
137
|
+
if (previous && !same(previous, reply)) {
|
|
138
|
+
conflict("A reply ID refers to different immutable reply content.", { entity: "reply" });
|
|
139
|
+
}
|
|
140
|
+
if (!previous) merged.set(reply.id, clone(reply));
|
|
141
|
+
}
|
|
142
|
+
return [...merged.values()].sort((a, b) => compareStable(a, b, "id"));
|
|
143
|
+
}
|
|
144
|
+
var immutablePin = (pin) => ({
|
|
145
|
+
id: pin.id,
|
|
146
|
+
author: pin.author,
|
|
147
|
+
artifactId: pin.artifactId,
|
|
148
|
+
...pin.variant === void 0 ? {} : { variant: pin.variant },
|
|
149
|
+
region: pin.region,
|
|
150
|
+
viewport: pin.viewport,
|
|
151
|
+
...pin.anchor === void 0 ? {} : { anchor: pin.anchor },
|
|
152
|
+
createdAt: pin.createdAt
|
|
153
|
+
});
|
|
154
|
+
var mutablePin = (pin) => ({
|
|
155
|
+
intent: pin.intent,
|
|
156
|
+
status: pin.status,
|
|
157
|
+
comment: pin.comment
|
|
158
|
+
});
|
|
159
|
+
function mergePin(stored, incoming) {
|
|
160
|
+
if (!same(immutablePin(stored), immutablePin(incoming))) {
|
|
161
|
+
conflict("A pin ID refers to different immutable geometry, author, or artifact content.", {
|
|
162
|
+
entity: "pin"
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const replies = mergeReplies(stored.replies, incoming.replies);
|
|
166
|
+
const storedTime = timeValue(stored.updatedAt);
|
|
167
|
+
const incomingTime = timeValue(incoming.updatedAt);
|
|
168
|
+
let selected = stored;
|
|
169
|
+
if (incomingTime > storedTime) selected = incoming;
|
|
170
|
+
if (incomingTime === storedTime && !same(mutablePin(stored), mutablePin(incoming))) {
|
|
171
|
+
conflict("A pin has divergent mutable content at the same update time.", { entity: "pin" });
|
|
172
|
+
}
|
|
173
|
+
return { ...clone(selected), replies };
|
|
174
|
+
}
|
|
175
|
+
function mergePins(stored = [], incoming = []) {
|
|
176
|
+
const merged = /* @__PURE__ */ new Map();
|
|
177
|
+
for (const pin of stored) merged.set(pin.id, clone(pin));
|
|
178
|
+
for (const pin of incoming) {
|
|
179
|
+
const previous = merged.get(pin.id);
|
|
180
|
+
merged.set(pin.id, previous ? mergePin(previous, pin) : clone(pin));
|
|
181
|
+
}
|
|
182
|
+
return [...merged.values()].sort((a, b) => compareStable(a, b, "id"));
|
|
183
|
+
}
|
|
184
|
+
var immutableReview = (review) => ({
|
|
185
|
+
schemaVersion: review.schemaVersion,
|
|
186
|
+
reviewId: review.reviewId,
|
|
187
|
+
reviewOf: review.reviewOf,
|
|
188
|
+
...review.createdAt === void 0 ? {} : { createdAt: review.createdAt }
|
|
189
|
+
});
|
|
190
|
+
var mutableReview = (review) => ({ decision: review.decision, overall: review.overall });
|
|
191
|
+
function mergeArtifactReviews(stored, incoming) {
|
|
192
|
+
validateArtifactReview(stored);
|
|
193
|
+
validateArtifactReview(incoming);
|
|
194
|
+
assertUniqueReviewItemIds(stored);
|
|
195
|
+
assertUniqueReviewItemIds(incoming);
|
|
196
|
+
if (stored.reviewId !== incoming.reviewId) {
|
|
197
|
+
conflict("Only reviews with the same review ID can be revision-merged.", {
|
|
198
|
+
entity: "review"
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (same(stored, incoming)) return stored;
|
|
202
|
+
if (!same(immutableReview(stored), immutableReview(incoming))) {
|
|
203
|
+
conflict("A review ID refers to a different digest or creation identity.", {
|
|
204
|
+
entity: "review"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const storedTime = timeValue(stored.updatedAt);
|
|
208
|
+
const incomingTime = timeValue(incoming.updatedAt);
|
|
209
|
+
let selected = stored;
|
|
210
|
+
if (incomingTime > storedTime) selected = incoming;
|
|
211
|
+
if (incomingTime === storedTime && !same(mutableReview(stored), mutableReview(incoming))) {
|
|
212
|
+
conflict("A review has divergent verdict or overall feedback at the same update time.", {
|
|
213
|
+
entity: "review"
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const merged = {
|
|
217
|
+
...clone(selected),
|
|
218
|
+
pins: mergePins(stored.pins, incoming.pins)
|
|
219
|
+
};
|
|
220
|
+
validateArtifactReview(merged);
|
|
221
|
+
return merged;
|
|
222
|
+
}
|
|
223
|
+
function validateReviewLedger(ledger) {
|
|
224
|
+
if (!ledger || typeof ledger !== "object" || Array.isArray(ledger) || ledger.schemaVersion !== ARTIFACT_REVIEW_STATE_VERSION || ledger.kind !== ARTIFACT_REVIEW_STATE_KIND || !ARTIFACT_ID_RE.test(ledger.artifactId ?? "") || !SHA256_RE.test(ledger.currentReviewOf ?? "") || !Array.isArray(ledger.reviews)) {
|
|
225
|
+
throw new PipelineError(
|
|
226
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
227
|
+
"Artifact review state is not a valid versioned review ledger."
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const ids = /* @__PURE__ */ new Set();
|
|
231
|
+
for (const entry of ledger.reviews) {
|
|
232
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.stale !== "boolean" || !entry.review) {
|
|
233
|
+
throw new PipelineError(
|
|
234
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
235
|
+
"Artifact review ledger entry is invalid."
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
validateArtifactReview(entry.review);
|
|
239
|
+
assertUniqueReviewItemIds(entry.review);
|
|
240
|
+
if (ids.has(entry.review.reviewId)) {
|
|
241
|
+
throw new PipelineError(
|
|
242
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
243
|
+
"Artifact review ledger IDs must be unique."
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
ids.add(entry.review.reviewId);
|
|
247
|
+
}
|
|
248
|
+
return ledger;
|
|
249
|
+
}
|
|
250
|
+
function createReviewLedger({ artifactId, currentReviewOf, reviews = [] } = {}) {
|
|
251
|
+
const ledger = {
|
|
252
|
+
schemaVersion: ARTIFACT_REVIEW_STATE_VERSION,
|
|
253
|
+
kind: ARTIFACT_REVIEW_STATE_KIND,
|
|
254
|
+
artifactId,
|
|
255
|
+
currentReviewOf,
|
|
256
|
+
reviews: reviews.map((entry) => ({ review: clone(entry.review), stale: Boolean(entry.stale) }))
|
|
257
|
+
};
|
|
258
|
+
ledger.reviews.sort((a, b) => compareStable(a.review, b.review, "reviewId"));
|
|
259
|
+
return validateReviewLedger(ledger);
|
|
260
|
+
}
|
|
261
|
+
function mergeReviewLedger(ledger, incoming, { stale = false } = {}) {
|
|
262
|
+
validateReviewLedger(ledger);
|
|
263
|
+
const reviews = Array.isArray(incoming) ? incoming : [incoming];
|
|
264
|
+
const byId = new Map(
|
|
265
|
+
ledger.reviews.map((entry) => [
|
|
266
|
+
entry.review.reviewId,
|
|
267
|
+
{
|
|
268
|
+
review: clone(entry.review),
|
|
269
|
+
stale: entry.stale
|
|
270
|
+
}
|
|
271
|
+
])
|
|
272
|
+
);
|
|
273
|
+
for (const review of reviews) {
|
|
274
|
+
validateArtifactReview(review);
|
|
275
|
+
const previous = byId.get(review.reviewId);
|
|
276
|
+
if (previous) {
|
|
277
|
+
byId.set(review.reviewId, {
|
|
278
|
+
review: mergeArtifactReviews(previous.review, review),
|
|
279
|
+
stale: previous.stale || Boolean(stale)
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
byId.set(review.reviewId, { review: clone(review), stale: Boolean(stale) });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const merged = createReviewLedger({
|
|
286
|
+
artifactId: ledger.artifactId,
|
|
287
|
+
currentReviewOf: ledger.currentReviewOf,
|
|
288
|
+
reviews: [...byId.values()]
|
|
289
|
+
});
|
|
290
|
+
return same(ledger, merged) ? ledger : merged;
|
|
291
|
+
}
|
|
292
|
+
function effectiveReviewDecision(value) {
|
|
293
|
+
const entries = Array.isArray(value) ? value : value?.reviews ?? [];
|
|
294
|
+
const decisions = entries.map((entry) => entry.review?.decision ?? entry.decision);
|
|
295
|
+
if (decisions.includes("changes_requested")) return "changes_requested";
|
|
296
|
+
if (decisions.includes("pending")) return "pending";
|
|
297
|
+
return decisions.length > 0 && decisions.every((decision) => decision === "approved") ? "approved" : "pending";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// packages/artifact/lib/artifact/review.mjs
|
|
301
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
302
|
+
import {
|
|
303
|
+
existsSync,
|
|
304
|
+
lstatSync,
|
|
305
|
+
mkdirSync,
|
|
306
|
+
readFileSync,
|
|
307
|
+
renameSync,
|
|
308
|
+
rmSync,
|
|
309
|
+
statSync,
|
|
310
|
+
writeFileSync
|
|
311
|
+
} from "node:fs";
|
|
312
|
+
import { dirname } from "node:path";
|
|
313
|
+
var ARTIFACT_REVIEW_MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
314
|
+
var reviewPathQueues = /* @__PURE__ */ new Map();
|
|
315
|
+
function finalEntry(path, fs = { lstatSync }) {
|
|
316
|
+
try {
|
|
317
|
+
return fs.lstatSync(path);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error?.code === "ENOENT") return null;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function clone2(value) {
|
|
324
|
+
return structuredClone(value);
|
|
325
|
+
}
|
|
326
|
+
function canonicalObject(value) {
|
|
327
|
+
if (Array.isArray(value)) return value.map(canonicalObject);
|
|
328
|
+
if (!value || typeof value !== "object") return value;
|
|
329
|
+
return Object.fromEntries(
|
|
330
|
+
Object.keys(value).sort().map((key) => [key, canonicalObject(value[key])])
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
function stableItemSort(a, b) {
|
|
334
|
+
return String(a.createdAt ?? a.updatedAt ?? "").localeCompare(
|
|
335
|
+
String(b.createdAt ?? b.updatedAt ?? "")
|
|
336
|
+
) || String(a.id ?? a.reviewId).localeCompare(String(b.id ?? b.reviewId));
|
|
337
|
+
}
|
|
338
|
+
function normalizeArtifactReview(review) {
|
|
339
|
+
let normalized;
|
|
340
|
+
try {
|
|
341
|
+
normalized = clone2(review);
|
|
342
|
+
} catch {
|
|
343
|
+
throw new PipelineError(
|
|
344
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
345
|
+
"Artifact review is not cloneable."
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
validateArtifactReview(normalized);
|
|
350
|
+
assertUniqueReviewItemIds(normalized);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof PipelineError) {
|
|
353
|
+
throw new PipelineError(
|
|
354
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
355
|
+
"Artifact review does not satisfy the Protocol v1.1 review contract.",
|
|
356
|
+
"",
|
|
357
|
+
{ issues: error.details?.issues ?? [] }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
normalized.pins = normalized.pins.map((pin) => ({
|
|
363
|
+
...pin,
|
|
364
|
+
replies: [...pin.replies].sort(stableItemSort)
|
|
365
|
+
})).sort(stableItemSort);
|
|
366
|
+
validateArtifactReview(normalized);
|
|
367
|
+
return normalized;
|
|
368
|
+
}
|
|
369
|
+
function serializeReviewState(value) {
|
|
370
|
+
const normalized = value?.kind === "artifact-review-state" ? validateReviewLedger(clone2(value)) : normalizeArtifactReview(value);
|
|
371
|
+
return `${JSON.stringify(canonicalObject(normalized), null, 2)}
|
|
372
|
+
`;
|
|
373
|
+
}
|
|
374
|
+
function readArtifactReviewState(path, { allowMissing = false } = {}) {
|
|
375
|
+
const entry = finalEntry(path);
|
|
376
|
+
if (!entry) {
|
|
377
|
+
if (allowMissing) return null;
|
|
378
|
+
throw new PipelineError(
|
|
379
|
+
ARTIFACT_ERROR_CODES.REVIEW_IMPORT,
|
|
380
|
+
"Artifact review state does not exist."
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
385
|
+
throw new PipelineError(
|
|
386
|
+
ARTIFACT_ERROR_CODES.SYMLINK_ESCAPE,
|
|
387
|
+
"Artifact review state destination is not a regular file."
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (statSync(path).size > ARTIFACT_REVIEW_MAX_STATE_BYTES) {
|
|
391
|
+
throw new PipelineError(
|
|
392
|
+
ARTIFACT_ERROR_CODES.REQUEST_LIMIT,
|
|
393
|
+
`Artifact review state exceeds ${ARTIFACT_REVIEW_MAX_STATE_BYTES} bytes.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return validateReviewLedger(JSON.parse(readFileSync(path, "utf8")));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error instanceof PipelineError) throw error;
|
|
399
|
+
throw new PipelineError(
|
|
400
|
+
ARTIFACT_ERROR_CODES.REVIEW_INVALID,
|
|
401
|
+
"Artifact review state is malformed."
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function writeArtifactReviewState(path, ledger, { fileSystem = {}, suffix = `${process.pid}.${randomBytes(8).toString("hex")}` } = {}) {
|
|
406
|
+
validateReviewLedger(ledger);
|
|
407
|
+
const fs = { existsSync, lstatSync, mkdirSync, writeFileSync, renameSync, rmSync, ...fileSystem };
|
|
408
|
+
const temporary = `${path}.${suffix}.tmp`;
|
|
409
|
+
const serialized = serializeReviewState(ledger);
|
|
410
|
+
if (Buffer.byteLength(serialized, "utf8") > ARTIFACT_REVIEW_MAX_STATE_BYTES) {
|
|
411
|
+
throw new PipelineError(
|
|
412
|
+
ARTIFACT_ERROR_CODES.REQUEST_LIMIT,
|
|
413
|
+
`Artifact review state exceeds ${ARTIFACT_REVIEW_MAX_STATE_BYTES} bytes.`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const initial = finalEntry(path, fs);
|
|
418
|
+
if (initial) {
|
|
419
|
+
const target = initial;
|
|
420
|
+
if (target.isSymbolicLink() || !target.isFile()) throw new Error("unsafe final target");
|
|
421
|
+
}
|
|
422
|
+
fs.mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
423
|
+
fs.writeFileSync(temporary, serialized, { mode: 384, flag: "wx" });
|
|
424
|
+
const final = finalEntry(path, fs);
|
|
425
|
+
if (final) {
|
|
426
|
+
const target = final;
|
|
427
|
+
if (target.isSymbolicLink() || !target.isFile()) throw new Error("unsafe final target");
|
|
428
|
+
}
|
|
429
|
+
fs.renameSync(temporary, path);
|
|
430
|
+
} catch {
|
|
431
|
+
try {
|
|
432
|
+
fs.rmSync(temporary, { force: true });
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
throw new PipelineError(
|
|
436
|
+
ARTIFACT_ERROR_CODES.REVIEW_WRITE,
|
|
437
|
+
"Artifact review state could not be written atomically.",
|
|
438
|
+
"Check destination permissions and retry; the previous review state was left unchanged."
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return ledger;
|
|
442
|
+
}
|
|
443
|
+
function withArtifactReviewLock(path, action) {
|
|
444
|
+
const previous = reviewPathQueues.get(path) ?? Promise.resolve();
|
|
445
|
+
const run = async () => {
|
|
446
|
+
let release;
|
|
447
|
+
try {
|
|
448
|
+
release = await acquireStartLock(`${path}.lock`, { timeout: 15e3, stale: 3e4 });
|
|
449
|
+
return await action();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error instanceof PipelineError) throw error;
|
|
452
|
+
throw new PipelineError(
|
|
453
|
+
ARTIFACT_ERROR_CODES.REVIEW_WRITE,
|
|
454
|
+
"Artifact review state is busy or its cross-process lock is unavailable.",
|
|
455
|
+
"Wait for the active review write to finish, then retry."
|
|
456
|
+
);
|
|
457
|
+
} finally {
|
|
458
|
+
release?.();
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const operation = previous.then(run, run);
|
|
462
|
+
const tail = operation.then(
|
|
463
|
+
() => void 0,
|
|
464
|
+
() => void 0
|
|
465
|
+
);
|
|
466
|
+
reviewPathQueues.set(path, tail);
|
|
467
|
+
tail.finally(() => {
|
|
468
|
+
if (reviewPathQueues.get(path) === tail) reviewPathQueues.delete(path);
|
|
469
|
+
});
|
|
470
|
+
return operation;
|
|
471
|
+
}
|
|
472
|
+
function normalizedExportInput(value) {
|
|
473
|
+
if (value?.kind === "artifact-review-state") return validateReviewLedger(clone2(value));
|
|
474
|
+
if (value?.review && value?.artifacts) return normalizeArtifactReview(value.review);
|
|
475
|
+
return normalizeArtifactReview(value);
|
|
476
|
+
}
|
|
477
|
+
function markdownText(value) {
|
|
478
|
+
return String(value ?? "").replace(/\r\n?/g, "\n").trim();
|
|
479
|
+
}
|
|
480
|
+
function renderPinMarkdown(pin, number) {
|
|
481
|
+
const region = `${pin.region.x}, ${pin.region.y}, ${pin.region.w}, ${pin.region.h}`;
|
|
482
|
+
const lines = [
|
|
483
|
+
`### ${number}. ${pin.intent.toUpperCase()} \xB7 ${pin.status}`,
|
|
484
|
+
"",
|
|
485
|
+
`- Pin: \`${pin.id}\``,
|
|
486
|
+
`- Artifact: \`${pin.artifactId}\`${pin.variant ? ` \xB7 variant \`${pin.variant}\`` : ""}`,
|
|
487
|
+
`- Author: ${pin.author.name}`,
|
|
488
|
+
`- Region: \`${region}\` at ${pin.viewport.width}\xD7${pin.viewport.height}`,
|
|
489
|
+
...pin.anchor ? [
|
|
490
|
+
`- Anchor: \`${pin.anchor.planrId}\`${pin.anchor.screen ? ` \xB7 screen \`${pin.anchor.screen}\`` : ""}`
|
|
491
|
+
] : [],
|
|
492
|
+
`- Updated: ${pin.updatedAt}`,
|
|
493
|
+
"",
|
|
494
|
+
markdownText(pin.comment)
|
|
495
|
+
];
|
|
496
|
+
if (pin.replies.length > 0) {
|
|
497
|
+
lines.push("", "#### Thread", "");
|
|
498
|
+
for (const reply of pin.replies) {
|
|
499
|
+
lines.push(`- **${reply.author.name}** (${reply.createdAt}): ${markdownText(reply.comment)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return lines.join("\n");
|
|
503
|
+
}
|
|
504
|
+
function reviewMarkdown(review, headingLevel = 2, stale = false) {
|
|
505
|
+
const mark = "#".repeat(headingLevel);
|
|
506
|
+
const lines = [
|
|
507
|
+
`${mark} Review ${review.reviewId}${stale ? " \xB7 STALE" : ""}`,
|
|
508
|
+
"",
|
|
509
|
+
`- Digest: \`${review.reviewOf}\``,
|
|
510
|
+
`- Decision: **${review.decision}**`,
|
|
511
|
+
`- Pins: ${review.pins.length}`,
|
|
512
|
+
...review.createdAt ? [`- Created: ${review.createdAt}`] : [],
|
|
513
|
+
...review.updatedAt ? [`- Updated: ${review.updatedAt}`] : [],
|
|
514
|
+
"",
|
|
515
|
+
`${mark}# Overall feedback`,
|
|
516
|
+
"",
|
|
517
|
+
markdownText(review.overall) || "_No overall feedback._"
|
|
518
|
+
];
|
|
519
|
+
if (review.pins.length > 0) {
|
|
520
|
+
lines.push("", `${mark}# Pins`, "");
|
|
521
|
+
review.pins.forEach((pin, index) => lines.push(renderPinMarkdown(pin, index + 1), ""));
|
|
522
|
+
if (lines.at(-1) === "") lines.pop();
|
|
523
|
+
}
|
|
524
|
+
return lines.join("\n");
|
|
525
|
+
}
|
|
526
|
+
function exportArtifactReview(value, { format = "json" } = {}) {
|
|
527
|
+
let normalized;
|
|
528
|
+
try {
|
|
529
|
+
normalized = normalizedExportInput(value);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error instanceof PipelineError) throw error;
|
|
532
|
+
throw new PipelineError(
|
|
533
|
+
ARTIFACT_ERROR_CODES.REVIEW_EXPORT,
|
|
534
|
+
"Artifact review cannot be exported."
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (format === "json") return serializeReviewState(normalized);
|
|
538
|
+
if (format !== "markdown") {
|
|
539
|
+
throw new PipelineError(
|
|
540
|
+
ARTIFACT_ERROR_CODES.REVIEW_EXPORT,
|
|
541
|
+
"Artifact review export format must be json or markdown."
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (normalized.kind !== "artifact-review-state") {
|
|
545
|
+
return `# OpenPlanr Artifact Review
|
|
546
|
+
|
|
547
|
+
${reviewMarkdown(normalized)}
|
|
548
|
+
`;
|
|
549
|
+
}
|
|
550
|
+
const lines = [
|
|
551
|
+
"# OpenPlanr Artifact Reviews",
|
|
552
|
+
"",
|
|
553
|
+
`- Artifact: \`${normalized.artifactId}\``,
|
|
554
|
+
`- Current digest: \`${normalized.currentReviewOf}\``,
|
|
555
|
+
`- Effective decision: **${effectiveReviewDecision(normalized)}**`,
|
|
556
|
+
`- Reviews: ${normalized.reviews.length}`
|
|
557
|
+
];
|
|
558
|
+
for (const entry of normalized.reviews) {
|
|
559
|
+
lines.push("", reviewMarkdown(entry.review, 2, entry.stale));
|
|
560
|
+
}
|
|
561
|
+
return `${lines.join("\n")}
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// packages/artifact/lib/artifact/import.mjs
|
|
566
|
+
var ARTIFACT_ID_RE2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
567
|
+
function pathError(code, message) {
|
|
568
|
+
throw new PipelineError(
|
|
569
|
+
code,
|
|
570
|
+
message,
|
|
571
|
+
"Choose a real, non-symlinked project or user review destination."
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
function pathEntry(path, fs = { lstatSync: lstatSync2 }) {
|
|
575
|
+
try {
|
|
576
|
+
return fs.lstatSync(path);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error?.code === "ENOENT") return null;
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function inside(base, candidate) {
|
|
583
|
+
const rel = relative(base, candidate);
|
|
584
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
585
|
+
}
|
|
586
|
+
function parseablePlanrConfig(root) {
|
|
587
|
+
const path = join(root, ".planr", "config.json");
|
|
588
|
+
if (!existsSync2(path)) return false;
|
|
589
|
+
try {
|
|
590
|
+
if (lstatSync2(path).isSymbolicLink()) return false;
|
|
591
|
+
const value = JSON.parse(readFileSync2(path, "utf8"));
|
|
592
|
+
return value && typeof value === "object" && !Array.isArray(value) && (typeof value.projectName === "string" && value.projectName.trim() !== "" || value.idPrefix && typeof value.idPrefix === "object" && !Array.isArray(value.idPrefix) && Object.keys(value.idPrefix).length > 0);
|
|
593
|
+
} catch {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function hasGitMarker(root) {
|
|
598
|
+
const marker = join(root, ".git");
|
|
599
|
+
if (!existsSync2(marker)) return false;
|
|
600
|
+
try {
|
|
601
|
+
const stat = lstatSync2(marker);
|
|
602
|
+
if (stat.isSymbolicLink()) return false;
|
|
603
|
+
if (stat.isDirectory()) {
|
|
604
|
+
const head = join(marker, "HEAD");
|
|
605
|
+
return existsSync2(head) && lstatSync2(head).isFile();
|
|
606
|
+
}
|
|
607
|
+
if (!stat.isFile() || stat.size > 4096) return false;
|
|
608
|
+
const match = /^gitdir:\s*(.+?)\s*$/u.exec(readFileSync2(marker, "utf8"));
|
|
609
|
+
if (!match) return false;
|
|
610
|
+
const gitDir = resolve(root, match[1]);
|
|
611
|
+
return existsSync2(gitDir) && statSync2(gitDir).isDirectory() && existsSync2(join(gitDir, "HEAD")) && lstatSync2(join(gitDir, "HEAD")).isFile();
|
|
612
|
+
} catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function findArtifactProjectRoot(start = process.cwd(), { env = process.env } = {}) {
|
|
617
|
+
let current;
|
|
618
|
+
try {
|
|
619
|
+
current = realpathSync(resolve(start));
|
|
620
|
+
} catch {
|
|
621
|
+
pathError(
|
|
622
|
+
ARTIFACT_ERROR_CODES.REVIEW_IMPORT,
|
|
623
|
+
"Artifact review working directory does not exist."
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const homeCandidate = resolve(env.HOME ?? homedir());
|
|
627
|
+
let home = homeCandidate;
|
|
628
|
+
try {
|
|
629
|
+
home = realpathSync(homeCandidate);
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
while (true) {
|
|
633
|
+
if (current !== home && (parseablePlanrConfig(current) || hasGitMarker(current)))
|
|
634
|
+
return current;
|
|
635
|
+
const parent = dirname2(current);
|
|
636
|
+
if (parent === current) return null;
|
|
637
|
+
current = parent;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function assertSafeDestination(base, relativeParts) {
|
|
641
|
+
const absoluteBase = resolve(base);
|
|
642
|
+
if (existsSync2(absoluteBase) && lstatSync2(absoluteBase).isSymbolicLink()) {
|
|
643
|
+
pathError(
|
|
644
|
+
ARTIFACT_ERROR_CODES.SYMLINK_ESCAPE,
|
|
645
|
+
"Artifact review destination base is a symlink."
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
let realBase = absoluteBase;
|
|
649
|
+
if (existsSync2(absoluteBase)) realBase = realpathSync(absoluteBase);
|
|
650
|
+
let current = absoluteBase;
|
|
651
|
+
for (const part of relativeParts) {
|
|
652
|
+
if (!part || part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0")) {
|
|
653
|
+
pathError(
|
|
654
|
+
ARTIFACT_ERROR_CODES.PATH_TRAVERSAL,
|
|
655
|
+
"Artifact review destination contains an unsafe segment."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
current = join(current, part);
|
|
659
|
+
const entry = pathEntry(current);
|
|
660
|
+
if (!entry) continue;
|
|
661
|
+
if (entry.isSymbolicLink()) {
|
|
662
|
+
pathError(
|
|
663
|
+
ARTIFACT_ERROR_CODES.SYMLINK_ESCAPE,
|
|
664
|
+
"Artifact review destination contains a symlink."
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (!inside(realBase, realpathSync(current))) {
|
|
668
|
+
pathError(
|
|
669
|
+
ARTIFACT_ERROR_CODES.PATH_TRAVERSAL,
|
|
670
|
+
"Artifact review destination escapes its storage root."
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!inside(absoluteBase, current)) {
|
|
675
|
+
pathError(
|
|
676
|
+
ARTIFACT_ERROR_CODES.PATH_TRAVERSAL,
|
|
677
|
+
"Artifact review destination escapes its storage root."
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return current;
|
|
681
|
+
}
|
|
682
|
+
function resolveArtifactReviewDestination({
|
|
683
|
+
cwd = process.cwd(),
|
|
684
|
+
env = process.env,
|
|
685
|
+
artifactId,
|
|
686
|
+
designDir
|
|
687
|
+
} = {}) {
|
|
688
|
+
if (!ARTIFACT_ID_RE2.test(artifactId ?? "")) {
|
|
689
|
+
pathError(
|
|
690
|
+
ARTIFACT_ERROR_CODES.PATH_TRAVERSAL,
|
|
691
|
+
"Artifact review ID is unsafe for local storage."
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (designDir !== void 0) {
|
|
695
|
+
const lexical = resolve(designDir);
|
|
696
|
+
if (!existsSync2(lexical) || !statSync2(lexical).isDirectory()) {
|
|
697
|
+
pathError(ARTIFACT_ERROR_CODES.REVIEW_IMPORT, "Design review destination does not exist.");
|
|
698
|
+
}
|
|
699
|
+
if (lstatSync2(lexical).isSymbolicLink()) {
|
|
700
|
+
pathError(
|
|
701
|
+
ARTIFACT_ERROR_CODES.SYMLINK_ESCAPE,
|
|
702
|
+
"Design review destination must be a real directory."
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const requested = realpathSync(lexical);
|
|
706
|
+
return Object.freeze({
|
|
707
|
+
kind: "design",
|
|
708
|
+
artifactId,
|
|
709
|
+
directory: requested,
|
|
710
|
+
path: join(requested, FEEDBACK_FILE),
|
|
711
|
+
reviewStatePath: join(requested, "artifact-review-state.json")
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
const projectRoot = findArtifactProjectRoot(cwd, { env });
|
|
715
|
+
if (projectRoot) {
|
|
716
|
+
const directory2 = assertSafeDestination(projectRoot, [".planr", "artifacts", artifactId]);
|
|
717
|
+
return Object.freeze({
|
|
718
|
+
kind: "project",
|
|
719
|
+
artifactId,
|
|
720
|
+
root: projectRoot,
|
|
721
|
+
directory: directory2,
|
|
722
|
+
path: join(directory2, "review-state.json")
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
const root = resolve(planrHome(env));
|
|
726
|
+
const directory = assertSafeDestination(root, ["artifacts", artifactId]);
|
|
727
|
+
return Object.freeze({
|
|
728
|
+
kind: "user",
|
|
729
|
+
artifactId,
|
|
730
|
+
root,
|
|
731
|
+
directory,
|
|
732
|
+
path: join(directory, "review-state.json")
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// packages/design/lib/design/workspace-client.mjs
|
|
737
|
+
var DESIGN_SHARE_BASE_URL = "https://share.openplanr.dev";
|
|
738
|
+
var encoder = new TextEncoder();
|
|
739
|
+
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
740
|
+
var tokenPattern = /^[A-Za-z0-9_-]{43}$/;
|
|
741
|
+
var idPattern = /^[A-Za-z0-9_-]{22,64}$/;
|
|
742
|
+
var ec = { name: "ECDSA", namedCurve: "P-256" };
|
|
743
|
+
var signatureAlgorithm = { name: "ECDSA", hash: "SHA-256" };
|
|
744
|
+
var omitSignature = ({ signature: _signature, ...value }) => value;
|
|
745
|
+
function encodeWorkspaceBytes(bytes) {
|
|
746
|
+
let binary = "";
|
|
747
|
+
for (let offset = 0; offset < bytes.length; offset += 8192)
|
|
748
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
|
|
749
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
750
|
+
}
|
|
751
|
+
function decodeWorkspaceBytes(value) {
|
|
752
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/u.test(value))
|
|
753
|
+
throw new TypeError("Invalid encoded workspace value.");
|
|
754
|
+
const decoded = Uint8Array.from(
|
|
755
|
+
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
|
756
|
+
(char) => char.charCodeAt(0)
|
|
757
|
+
);
|
|
758
|
+
if (encodeWorkspaceBytes(decoded) !== value)
|
|
759
|
+
throw new TypeError("Noncanonical encoded workspace value.");
|
|
760
|
+
return decoded;
|
|
761
|
+
}
|
|
762
|
+
function newWorkspaceToken() {
|
|
763
|
+
return encodeWorkspaceBytes(crypto.getRandomValues(new Uint8Array(32)));
|
|
764
|
+
}
|
|
765
|
+
function newWorkspaceId() {
|
|
766
|
+
return encodeWorkspaceBytes(crypto.getRandomValues(new Uint8Array(18)));
|
|
767
|
+
}
|
|
768
|
+
function normalizeWorkspaceBase(baseUrl = DESIGN_SHARE_BASE_URL) {
|
|
769
|
+
const url = new URL(baseUrl);
|
|
770
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
|
|
771
|
+
throw new TypeError("Design sharing requires an HTTPS origin or a local development server.");
|
|
772
|
+
}
|
|
773
|
+
return url.origin;
|
|
774
|
+
}
|
|
775
|
+
function workspaceReviewUrl(access) {
|
|
776
|
+
if (!idPattern.test(access.id)) throw new TypeError("Invalid design workspace identity.");
|
|
777
|
+
return `${normalizeWorkspaceBase(access.baseUrl)}/d/${access.id}`;
|
|
778
|
+
}
|
|
779
|
+
async function tokenMaterial(token, id, purpose) {
|
|
780
|
+
if (!tokenPattern.test(token) || decodeWorkspaceBytes(token).length !== 32 || !idPattern.test(id))
|
|
781
|
+
throw new TypeError("Enter the complete generated access token.");
|
|
782
|
+
const key = await crypto.subtle.importKey("raw", decodeWorkspaceBytes(token), "HKDF", false, [
|
|
783
|
+
"deriveBits"
|
|
784
|
+
]);
|
|
785
|
+
return new Uint8Array(
|
|
786
|
+
await crypto.subtle.deriveBits(
|
|
787
|
+
{
|
|
788
|
+
name: "HKDF",
|
|
789
|
+
hash: "SHA-256",
|
|
790
|
+
salt: encoder.encode(id),
|
|
791
|
+
info: encoder.encode(`openplanr-design-workspace/v1/${purpose}`)
|
|
792
|
+
},
|
|
793
|
+
key,
|
|
794
|
+
256
|
|
795
|
+
)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
async function deriveWorkspaceAuthentication(token, id) {
|
|
799
|
+
return encodeWorkspaceBytes(await tokenMaterial(token, id, "reviewer-auth"));
|
|
800
|
+
}
|
|
801
|
+
function canonicalWorkspacePublicKey(value) {
|
|
802
|
+
if (!value || value.kty !== "EC" || value.crv !== "P-256" || !tokenPattern.test(value.x ?? "") || !tokenPattern.test(value.y ?? "") || "d" in value) {
|
|
803
|
+
throw new TypeError("Invalid design workspace public key.");
|
|
804
|
+
}
|
|
805
|
+
return { kty: "EC", crv: "P-256", x: value.x, y: value.y };
|
|
806
|
+
}
|
|
807
|
+
async function createWorkspaceSigner() {
|
|
808
|
+
const pair = await crypto.subtle.generateKey(ec, true, ["sign", "verify"]);
|
|
809
|
+
return {
|
|
810
|
+
privateKey: encodeWorkspaceBytes(
|
|
811
|
+
new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey))
|
|
812
|
+
),
|
|
813
|
+
publicKey: canonicalWorkspacePublicKey(await crypto.subtle.exportKey("jwk", pair.publicKey))
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
async function signWorkspaceValue(value, privateKey) {
|
|
817
|
+
const key = await crypto.subtle.importKey("pkcs8", decodeWorkspaceBytes(privateKey), ec, false, [
|
|
818
|
+
"sign"
|
|
819
|
+
]);
|
|
820
|
+
const signature = await crypto.subtle.sign(
|
|
821
|
+
signatureAlgorithm,
|
|
822
|
+
key,
|
|
823
|
+
encoder.encode(canonicalizeJson(omitSignature(value)))
|
|
824
|
+
);
|
|
825
|
+
return { ...omitSignature(value), signature: encodeWorkspaceBytes(new Uint8Array(signature)) };
|
|
826
|
+
}
|
|
827
|
+
async function verifyWorkspaceSignature(value, publicKey) {
|
|
828
|
+
try {
|
|
829
|
+
const key = await crypto.subtle.importKey("jwk", publicKey, ec, false, ["verify"]);
|
|
830
|
+
return await crypto.subtle.verify(
|
|
831
|
+
signatureAlgorithm,
|
|
832
|
+
key,
|
|
833
|
+
decodeWorkspaceBytes(value.signature),
|
|
834
|
+
encoder.encode(canonicalizeJson(omitSignature(value)))
|
|
835
|
+
);
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async function seal(value, rawKey, context, limit = DESIGN_WORKSPACE_MAX_BYTES) {
|
|
841
|
+
const bytes = encoder.encode(canonicalizeJson(value));
|
|
842
|
+
if (bytes.length + 16 > limit)
|
|
843
|
+
throw new RangeError(
|
|
844
|
+
`Encrypted design data exceeds the ${Math.floor(limit / 1024)} KB upload limit.`
|
|
845
|
+
);
|
|
846
|
+
const key = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt"]);
|
|
847
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
848
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
849
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(canonicalizeJson(context)) },
|
|
850
|
+
key,
|
|
851
|
+
bytes
|
|
852
|
+
);
|
|
853
|
+
return {
|
|
854
|
+
iv: encodeWorkspaceBytes(iv),
|
|
855
|
+
ciphertext: encodeWorkspaceBytes(new Uint8Array(ciphertext))
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
async function unseal(value, rawKey, context, limit = DESIGN_WORKSPACE_MAX_BYTES) {
|
|
859
|
+
const bytes = decodeWorkspaceBytes(value.ciphertext);
|
|
860
|
+
if (bytes.length > limit) throw new RangeError("Shared design data exceeds its size limit.");
|
|
861
|
+
const key = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["decrypt"]);
|
|
862
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
863
|
+
{
|
|
864
|
+
name: "AES-GCM",
|
|
865
|
+
iv: decodeWorkspaceBytes(value.iv),
|
|
866
|
+
additionalData: encoder.encode(canonicalizeJson(context))
|
|
867
|
+
},
|
|
868
|
+
key,
|
|
869
|
+
bytes
|
|
870
|
+
);
|
|
871
|
+
return JSON.parse(decoder.decode(plaintext));
|
|
872
|
+
}
|
|
873
|
+
var keyringContext = (id, epoch) => ({ type: "keyring", workspaceId: id, epoch });
|
|
874
|
+
var revisionContext = (id, revision) => ({
|
|
875
|
+
type: "revision",
|
|
876
|
+
workspaceId: id,
|
|
877
|
+
id: revision.id,
|
|
878
|
+
epoch: revision.epoch,
|
|
879
|
+
reviewOf: revision.reviewOf
|
|
880
|
+
});
|
|
881
|
+
var eventContext = (id, event) => ({
|
|
882
|
+
type: "event",
|
|
883
|
+
workspaceId: id,
|
|
884
|
+
id: event.id,
|
|
885
|
+
epoch: event.epoch,
|
|
886
|
+
revisionId: event.revisionId,
|
|
887
|
+
reviewOf: event.reviewOf
|
|
888
|
+
});
|
|
889
|
+
function workspaceEnvelopeDigest(envelope) {
|
|
890
|
+
return sha256Hex(
|
|
891
|
+
canonicalizeJson({
|
|
892
|
+
schemaVersion: envelope.schemaVersion,
|
|
893
|
+
artifacts: envelope.artifacts,
|
|
894
|
+
viewer: envelope.viewer
|
|
895
|
+
})
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
async function wrapKeyring(custody, token = custody.token, epoch = custody.epoch, keys = custody.keys) {
|
|
899
|
+
return seal(
|
|
900
|
+
{ keys, ownerPublicKey: custody.ownerPublicKey },
|
|
901
|
+
await tokenMaterial(token, custody.id, "key-wrap"),
|
|
902
|
+
keyringContext(custody.id, epoch)
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
async function prepareRevision(custody, bundle) {
|
|
906
|
+
assertDesignReviewBundle(bundle);
|
|
907
|
+
const header = {
|
|
908
|
+
id: newWorkspaceId(),
|
|
909
|
+
epoch: custody.epoch,
|
|
910
|
+
reviewOf: workspaceEnvelopeDigest(bundle.envelope),
|
|
911
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
912
|
+
};
|
|
913
|
+
return signWorkspaceValue(
|
|
914
|
+
{
|
|
915
|
+
...header,
|
|
916
|
+
...await seal(
|
|
917
|
+
bundle,
|
|
918
|
+
decodeWorkspaceBytes(custody.keys[custody.epoch]),
|
|
919
|
+
revisionContext(custody.id, header)
|
|
920
|
+
)
|
|
921
|
+
},
|
|
922
|
+
custody.ownerPrivateKey
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
async function prepareWorkspace(bundle, { baseUrl = DESIGN_SHARE_BASE_URL } = {}) {
|
|
926
|
+
const signer = await createWorkspaceSigner();
|
|
927
|
+
const custody = {
|
|
928
|
+
schemaVersion: DESIGN_WORKSPACE_VERSION,
|
|
929
|
+
id: newWorkspaceId(),
|
|
930
|
+
baseUrl: normalizeWorkspaceBase(baseUrl),
|
|
931
|
+
token: newWorkspaceToken(),
|
|
932
|
+
ownerAuth: newWorkspaceToken(),
|
|
933
|
+
ownerPrivateKey: signer.privateKey,
|
|
934
|
+
ownerPublicKey: signer.publicKey,
|
|
935
|
+
epoch: 1,
|
|
936
|
+
keys: { 1: newWorkspaceToken() },
|
|
937
|
+
version: 0
|
|
938
|
+
};
|
|
939
|
+
custody.keyring = await wrapKeyring(custody);
|
|
940
|
+
custody.pendingCreate = await signWorkspaceValue(
|
|
941
|
+
{
|
|
942
|
+
schemaVersion: DESIGN_WORKSPACE_VERSION,
|
|
943
|
+
id: custody.id,
|
|
944
|
+
ownerPublicKey: custody.ownerPublicKey,
|
|
945
|
+
ownerAuthHash: sha256Hex(custody.ownerAuth),
|
|
946
|
+
reviewerAuthHash: sha256Hex(await deriveWorkspaceAuthentication(custody.token, custody.id)),
|
|
947
|
+
epoch: 1,
|
|
948
|
+
keyring: custody.keyring,
|
|
949
|
+
revision: await prepareRevision(custody, bundle),
|
|
950
|
+
operationId: newWorkspaceId()
|
|
951
|
+
},
|
|
952
|
+
custody.ownerPrivateKey
|
|
953
|
+
);
|
|
954
|
+
assertWorkspaceContract(custody.pendingCreate, DESIGN_WORKSPACE_CREATE_SCHEMA);
|
|
955
|
+
return custody;
|
|
956
|
+
}
|
|
957
|
+
async function request(access, suffix = "", {
|
|
958
|
+
method = "GET",
|
|
959
|
+
body,
|
|
960
|
+
fetchImpl = globalThis.fetch,
|
|
961
|
+
owner = Boolean(access.ownerAuth),
|
|
962
|
+
timeoutMs = 2e4
|
|
963
|
+
} = {}) {
|
|
964
|
+
if (!idPattern.test(access.id)) throw new TypeError("Invalid design workspace identity.");
|
|
965
|
+
const authorization = owner ? access.ownerAuth : await deriveWorkspaceAuthentication(access.token, access.id);
|
|
966
|
+
const headers = { Authorization: `Bearer ${authorization}`, Accept: "application/json" };
|
|
967
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
968
|
+
let response;
|
|
969
|
+
try {
|
|
970
|
+
response = await fetchImpl(
|
|
971
|
+
`${normalizeWorkspaceBase(access.baseUrl)}${DESIGN_WORKSPACE_API}/${access.id}${suffix}`,
|
|
972
|
+
{
|
|
973
|
+
method,
|
|
974
|
+
headers,
|
|
975
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
976
|
+
redirect: "error",
|
|
977
|
+
cache: "no-store",
|
|
978
|
+
credentials: "omit",
|
|
979
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
980
|
+
}
|
|
981
|
+
);
|
|
982
|
+
} catch {
|
|
983
|
+
throw new Error(
|
|
984
|
+
"Design sharing is unreachable. Check the connection and retry; the previous review is unchanged."
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (!response.ok) {
|
|
988
|
+
const messages = {
|
|
989
|
+
401: "Access token is incorrect or has been rotated.",
|
|
990
|
+
403: "This review is unavailable or this action requires its owner.",
|
|
991
|
+
404: "This shared review could not be found.",
|
|
992
|
+
409: "The shared review changed. Refresh its status before retrying.",
|
|
993
|
+
410: "This shared review has been revoked or deleted.",
|
|
994
|
+
413: "This design exceeds the sharing upload limit.",
|
|
995
|
+
429: "Too many requests. Wait a moment and retry.",
|
|
996
|
+
503: "Design sharing is temporarily unavailable. Retry shortly."
|
|
997
|
+
};
|
|
998
|
+
const error = new Error(
|
|
999
|
+
messages[response.status] ?? `Design sharing failed (${response.status}). Retry shortly.`
|
|
1000
|
+
);
|
|
1001
|
+
error.status = response.status;
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
if (response.status === 204) return {};
|
|
1005
|
+
const limit = DESIGN_WORKSPACE_MAX_BYTES * 1.5 + 65536;
|
|
1006
|
+
const reader = response.body?.getReader();
|
|
1007
|
+
if (!reader) throw new Error("Design service returned an empty response.");
|
|
1008
|
+
const chunks = [];
|
|
1009
|
+
let length = 0;
|
|
1010
|
+
try {
|
|
1011
|
+
for (; ; ) {
|
|
1012
|
+
const { value, done } = await reader.read();
|
|
1013
|
+
if (done) break;
|
|
1014
|
+
length += value.length;
|
|
1015
|
+
if (length > limit) {
|
|
1016
|
+
await reader.cancel();
|
|
1017
|
+
throw new RangeError("Design service returned an oversized response.");
|
|
1018
|
+
}
|
|
1019
|
+
chunks.push(value);
|
|
1020
|
+
}
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (error instanceof RangeError) throw error;
|
|
1023
|
+
throw new Error("The sharing response was interrupted. Retry the pending operation.");
|
|
1024
|
+
}
|
|
1025
|
+
const bytes = new Uint8Array(length);
|
|
1026
|
+
let offset = 0;
|
|
1027
|
+
for (const chunk of chunks) {
|
|
1028
|
+
bytes.set(chunk, offset);
|
|
1029
|
+
offset += chunk.length;
|
|
1030
|
+
}
|
|
1031
|
+
return JSON.parse(decoder.decode(bytes));
|
|
1032
|
+
}
|
|
1033
|
+
async function commitWorkspace(custody, options = {}) {
|
|
1034
|
+
if (!custody.pendingCreate) return getWorkspace(custody, options);
|
|
1035
|
+
const result = await request(custody, "", {
|
|
1036
|
+
...options,
|
|
1037
|
+
method: "PUT",
|
|
1038
|
+
body: custody.pendingCreate
|
|
1039
|
+
});
|
|
1040
|
+
assertWorkspaceContract(result, DESIGN_WORKSPACE_SCHEMA);
|
|
1041
|
+
if (result.id !== custody.id || result.version !== 1 || result.epoch !== 1 || result.currentRevision !== custody.pendingCreate.revision.id || canonicalizeJson(result.ownerPublicKey) !== canonicalizeJson(custody.ownerPublicKey) || canonicalizeJson(result.keyring) !== canonicalizeJson(custody.keyring))
|
|
1042
|
+
throw new Error("The sharing creation receipt is invalid. Retry the saved operation.");
|
|
1043
|
+
custody.version = result.version;
|
|
1044
|
+
custody.currentRevision = result.currentRevision;
|
|
1045
|
+
delete custody.pendingCreate;
|
|
1046
|
+
return result;
|
|
1047
|
+
}
|
|
1048
|
+
async function getWorkspace(access, options = {}) {
|
|
1049
|
+
const result = assertWorkspaceContract(
|
|
1050
|
+
await request(access, "", options),
|
|
1051
|
+
DESIGN_WORKSPACE_SCHEMA
|
|
1052
|
+
);
|
|
1053
|
+
if (result.id !== access.id) throw new Error("Shared review identity mismatch.");
|
|
1054
|
+
const ring = await unseal(
|
|
1055
|
+
result.keyring,
|
|
1056
|
+
await tokenMaterial(access.token, access.id, "key-wrap"),
|
|
1057
|
+
keyringContext(access.id, result.epoch)
|
|
1058
|
+
);
|
|
1059
|
+
if (canonicalizeJson(ring.ownerPublicKey) !== canonicalizeJson(result.ownerPublicKey) || access.ownerPublicKey && canonicalizeJson(access.ownerPublicKey) !== canonicalizeJson(result.ownerPublicKey))
|
|
1060
|
+
throw new Error("Shared review owner identity changed.");
|
|
1061
|
+
if (!ring.keys || !ring.keys[result.epoch] || Object.entries(ring.keys).some(
|
|
1062
|
+
([keyEpoch, key]) => !/^[1-9][0-9]*$/u.test(keyEpoch) || !tokenPattern.test(key)
|
|
1063
|
+
))
|
|
1064
|
+
throw new Error("Invalid shared review key history.");
|
|
1065
|
+
Object.assign(access, {
|
|
1066
|
+
keys: ring.keys,
|
|
1067
|
+
ownerPublicKey: result.ownerPublicKey,
|
|
1068
|
+
epoch: result.epoch,
|
|
1069
|
+
keyring: result.keyring,
|
|
1070
|
+
version: result.version,
|
|
1071
|
+
currentRevision: result.currentRevision,
|
|
1072
|
+
commentsPaused: result.commentsPaused
|
|
1073
|
+
});
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
async function decryptWorkspaceRevision(access, revisionId = access.currentRevision, options = {}) {
|
|
1077
|
+
if (!access.keys) await getWorkspace(access, options);
|
|
1078
|
+
if (!idPattern.test(revisionId)) throw new TypeError("Invalid design revision.");
|
|
1079
|
+
const revision = assertWorkspaceContract(
|
|
1080
|
+
await request(access, `/revisions/${revisionId}`, options),
|
|
1081
|
+
DESIGN_WORKSPACE_REVISION_SCHEMA
|
|
1082
|
+
);
|
|
1083
|
+
if (revision.id !== revisionId || !await verifyWorkspaceSignature(revision, access.ownerPublicKey))
|
|
1084
|
+
throw new Error("The published design signature is invalid.");
|
|
1085
|
+
if (!access.keys[revision.epoch]) throw new Error("The access token cannot open this revision.");
|
|
1086
|
+
const bundle = assertDesignReviewBundle(
|
|
1087
|
+
await unseal(
|
|
1088
|
+
revision,
|
|
1089
|
+
decodeWorkspaceBytes(access.keys[revision.epoch]),
|
|
1090
|
+
revisionContext(access.id, revision)
|
|
1091
|
+
)
|
|
1092
|
+
);
|
|
1093
|
+
if (workspaceEnvelopeDigest(bundle.envelope) !== revision.reviewOf)
|
|
1094
|
+
throw new Error("Published design content does not match its revision.");
|
|
1095
|
+
return { ...bundle, workspaceRevision: revision.id, reviewOf: revision.reviewOf };
|
|
1096
|
+
}
|
|
1097
|
+
async function prepareWorkspaceMutation(custody, action, payload = void 0) {
|
|
1098
|
+
if (custody.pendingCreate)
|
|
1099
|
+
throw new Error("Finish creating this shared review before changing it.");
|
|
1100
|
+
if (custody.pendingMutation)
|
|
1101
|
+
throw new Error("A sharing operation is pending. Retry it before making another change.");
|
|
1102
|
+
const body = {
|
|
1103
|
+
operationId: newWorkspaceId(),
|
|
1104
|
+
expectedVersion: custody.version,
|
|
1105
|
+
epoch: custody.epoch
|
|
1106
|
+
};
|
|
1107
|
+
let next = {};
|
|
1108
|
+
if (action === "publish") body.revision = await prepareRevision(custody, payload);
|
|
1109
|
+
else if (action === "rotate") {
|
|
1110
|
+
const token = newWorkspaceToken();
|
|
1111
|
+
const epoch = custody.epoch + 1;
|
|
1112
|
+
const keys = { ...custody.keys, [epoch]: newWorkspaceToken() };
|
|
1113
|
+
const keyring = await wrapKeyring(custody, token, epoch, keys);
|
|
1114
|
+
Object.assign(body, {
|
|
1115
|
+
epoch,
|
|
1116
|
+
reviewerAuthHash: sha256Hex(await deriveWorkspaceAuthentication(token, custody.id)),
|
|
1117
|
+
keyring
|
|
1118
|
+
});
|
|
1119
|
+
next = { token, epoch, keys, keyring };
|
|
1120
|
+
} else if (["pause", "resume", "revoke", "delete"].includes(action)) body.action = action;
|
|
1121
|
+
else throw new TypeError("Unknown design sharing operation.");
|
|
1122
|
+
custody.pendingMutation = {
|
|
1123
|
+
action,
|
|
1124
|
+
body: await signWorkspaceValue(body, custody.ownerPrivateKey),
|
|
1125
|
+
next
|
|
1126
|
+
};
|
|
1127
|
+
return custody.pendingMutation;
|
|
1128
|
+
}
|
|
1129
|
+
async function commitWorkspaceMutation(custody, options = {}) {
|
|
1130
|
+
const pending = custody.pendingMutation;
|
|
1131
|
+
if (!pending) throw new Error("No sharing operation is pending.");
|
|
1132
|
+
const suffix = ["publish", "rotate"].includes(pending.action) ? pending.action : "manage";
|
|
1133
|
+
const result = await request(custody, `/${suffix}`, {
|
|
1134
|
+
...options,
|
|
1135
|
+
method: "POST",
|
|
1136
|
+
body: pending.body
|
|
1137
|
+
});
|
|
1138
|
+
if (pending.action === "delete") {
|
|
1139
|
+
if (result.schemaVersion !== DESIGN_WORKSPACE_VERSION || result.id !== custody.id || result.deleted !== true || Object.keys(result).some((key) => !["schemaVersion", "id", "deleted"].includes(key)))
|
|
1140
|
+
throw new Error("The deletion receipt is invalid. Retry the saved operation.");
|
|
1141
|
+
} else {
|
|
1142
|
+
assertWorkspaceContract(result, DESIGN_WORKSPACE_SCHEMA);
|
|
1143
|
+
const expectedRevision = pending.action === "publish" ? pending.body.revision.id : custody.currentRevision;
|
|
1144
|
+
if (result.id !== custody.id || result.version !== pending.body.expectedVersion + 1 || result.epoch !== pending.body.epoch || result.currentRevision !== expectedRevision || canonicalizeJson(result.ownerPublicKey) !== canonicalizeJson(custody.ownerPublicKey) || canonicalizeJson(result.keyring) !== canonicalizeJson(pending.next.keyring ?? custody.keyring) || ["pause", "resume"].includes(pending.action) && result.commentsPaused !== (pending.action === "pause"))
|
|
1145
|
+
throw new Error("The sharing operation receipt is invalid. Retry the saved operation.");
|
|
1146
|
+
}
|
|
1147
|
+
Object.assign(custody, pending.next, { version: pending.body.expectedVersion + 1 });
|
|
1148
|
+
if (pending.action === "publish") custody.currentRevision = pending.body.revision.id;
|
|
1149
|
+
if (pending.action === "pause" || pending.action === "resume")
|
|
1150
|
+
custody.commentsPaused = pending.action === "pause";
|
|
1151
|
+
if (pending.action === "revoke" || pending.action === "delete")
|
|
1152
|
+
custody.status = pending.action === "revoke" ? "revoked" : "deleted";
|
|
1153
|
+
delete custody.pendingMutation;
|
|
1154
|
+
return result;
|
|
1155
|
+
}
|
|
1156
|
+
async function prepareWorkspaceEvent(access, payload, { revisionId = access.currentRevision, reviewOf = payload.reviewOf, signer } = {}) {
|
|
1157
|
+
if (!access.keys) throw new Error("Unlock the shared review before commenting.");
|
|
1158
|
+
if (["category", "disposition"].includes(payload.kind)) assertDesignReviewMetadata(payload);
|
|
1159
|
+
if (!["review", "direction", "category", "disposition"].includes(payload.kind))
|
|
1160
|
+
throw new TypeError("Unknown design feedback kind.");
|
|
1161
|
+
if (typeof payload.author !== "string" || !payload.author.trim() || payload.author.length > 160)
|
|
1162
|
+
throw new TypeError("Enter your name before leaving feedback.");
|
|
1163
|
+
const identity = signer ?? await createWorkspaceSigner();
|
|
1164
|
+
const publicKey = canonicalWorkspacePublicKey(identity.publicKey);
|
|
1165
|
+
const header = { id: newWorkspaceId(), revisionId, reviewOf, epoch: access.epoch };
|
|
1166
|
+
const event = await signWorkspaceValue(
|
|
1167
|
+
{
|
|
1168
|
+
...header,
|
|
1169
|
+
...await seal(
|
|
1170
|
+
payload,
|
|
1171
|
+
decodeWorkspaceBytes(access.keys[access.epoch]),
|
|
1172
|
+
eventContext(access.id, header),
|
|
1173
|
+
DESIGN_WORKSPACE_MAX_EVENT_BYTES
|
|
1174
|
+
),
|
|
1175
|
+
publicKey
|
|
1176
|
+
},
|
|
1177
|
+
identity.privateKey
|
|
1178
|
+
);
|
|
1179
|
+
return assertWorkspaceContract(event, DESIGN_WORKSPACE_EVENT_SCHEMA);
|
|
1180
|
+
}
|
|
1181
|
+
async function appendWorkspaceEvent(access, payload, { preparedEvent, signer, revisionId, reviewOf, ...options } = {}) {
|
|
1182
|
+
const event = preparedEvent ?? await prepareWorkspaceEvent(access, payload, {
|
|
1183
|
+
signer,
|
|
1184
|
+
revisionId,
|
|
1185
|
+
reviewOf: reviewOf ?? payload.reviewOf
|
|
1186
|
+
});
|
|
1187
|
+
const result = await request(access, "/events", { ...options, method: "POST", body: event });
|
|
1188
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || !Number.isSafeInteger(result.sequence) || result.sequence < 1 || !result.event || typeof result.event !== "object" || Array.isArray(result.event))
|
|
1189
|
+
throw new Error("The feedback receipt is invalid. Retry the saved operation.");
|
|
1190
|
+
const { sequence: eventSequence, ...received } = result.event;
|
|
1191
|
+
if (eventSequence !== void 0 && eventSequence !== result.sequence || canonicalizeJson(received) !== canonicalizeJson(event))
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
"The feedback receipt does not match the saved event. Retry the saved operation."
|
|
1194
|
+
);
|
|
1195
|
+
return { event: { ...received, sequence: result.sequence }, sequence: result.sequence };
|
|
1196
|
+
}
|
|
1197
|
+
async function readWorkspaceEvents(access, { after = 0, ...options } = {}) {
|
|
1198
|
+
if (!Number.isSafeInteger(after) || after < 0) throw new TypeError("Invalid feedback cursor.");
|
|
1199
|
+
if (!access.keys) await getWorkspace(access, options);
|
|
1200
|
+
const page = await request(access, `/events?after=${after}`, options);
|
|
1201
|
+
if (!Array.isArray(page.events) || page.events.length > 100 || !Number.isSafeInteger(page.cursor) || page.cursor < after || typeof page.hasMore !== "boolean")
|
|
1202
|
+
throw new Error("Invalid shared feedback page.");
|
|
1203
|
+
const events = [];
|
|
1204
|
+
const issues = [];
|
|
1205
|
+
let previousSequence = after;
|
|
1206
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
1207
|
+
for (const item of page.events) {
|
|
1208
|
+
const { sequence, ...record } = item?.event ? { ...item.event, sequence: item.sequence } : item ?? {};
|
|
1209
|
+
if (!Number.isSafeInteger(sequence) || sequence <= previousSequence || sequence > page.cursor)
|
|
1210
|
+
throw new Error("Invalid shared feedback sequence.");
|
|
1211
|
+
previousSequence = sequence;
|
|
1212
|
+
if (typeof record.id !== "string" || pageIds.has(record.id))
|
|
1213
|
+
throw new Error("Invalid shared feedback event identity.");
|
|
1214
|
+
pageIds.add(record.id);
|
|
1215
|
+
try {
|
|
1216
|
+
assertWorkspaceContract(record, DESIGN_WORKSPACE_EVENT_SCHEMA);
|
|
1217
|
+
if (!await verifyWorkspaceSignature(record, record.publicKey))
|
|
1218
|
+
throw new Error("Signature verification failed.");
|
|
1219
|
+
if (!access.keys[record.epoch]) throw new Error("The encryption epoch is unavailable.");
|
|
1220
|
+
const payload = await unseal(
|
|
1221
|
+
record,
|
|
1222
|
+
decodeWorkspaceBytes(access.keys[record.epoch]),
|
|
1223
|
+
eventContext(access.id, record),
|
|
1224
|
+
DESIGN_WORKSPACE_MAX_EVENT_BYTES
|
|
1225
|
+
);
|
|
1226
|
+
if (["category", "disposition"].includes(payload.kind)) assertDesignReviewMetadata(payload);
|
|
1227
|
+
if (!["review", "direction", "category", "disposition"].includes(payload.kind) || typeof payload.author !== "string" || !payload.author.trim() || payload.author.length > 160 || payload.reviewOf !== record.reviewOf)
|
|
1228
|
+
throw new Error("Feedback author, kind or revision is invalid.");
|
|
1229
|
+
events.push({ ...record, sequence, payload });
|
|
1230
|
+
} catch {
|
|
1231
|
+
issues.push({
|
|
1232
|
+
sequence,
|
|
1233
|
+
id: typeof record.id === "string" && idPattern.test(record.id) ? record.id : null,
|
|
1234
|
+
reason: "Invalid encrypted feedback; not imported."
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (previousSequence > page.cursor) throw new Error("Invalid shared feedback cursor.");
|
|
1239
|
+
return { ...page, events, issues };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// packages/artifact/lib/artifact/ui/annotations.mjs
|
|
1243
|
+
var ARTIFACT_ANNOTATION_EVENTS = Object.freeze({
|
|
1244
|
+
draft: "planr:artifact-annotation-draft",
|
|
1245
|
+
focus: "planr:artifact-annotation-focus"
|
|
1246
|
+
});
|
|
1247
|
+
var ARTIFACT_ANNOTATION_LIMITS = Object.freeze({
|
|
1248
|
+
dragThreshold: 4,
|
|
1249
|
+
maxCommentLength: 65536,
|
|
1250
|
+
maxIdentityLength: 256
|
|
1251
|
+
});
|
|
1252
|
+
var INTENTS = Object.freeze(["fix", "improve", "question"]);
|
|
1253
|
+
|
|
1254
|
+
// packages/artifact/lib/artifact/ui/feedback-rail.mjs
|
|
1255
|
+
var ARTIFACT_REVIEW_LIMITS = Object.freeze({
|
|
1256
|
+
id: 128,
|
|
1257
|
+
authorName: 256,
|
|
1258
|
+
artifactId: 128,
|
|
1259
|
+
variant: 128,
|
|
1260
|
+
anchor: 512,
|
|
1261
|
+
screen: 128,
|
|
1262
|
+
text: 65536,
|
|
1263
|
+
pins: 1e4,
|
|
1264
|
+
replies: 1e4,
|
|
1265
|
+
viewport: 16384
|
|
1266
|
+
});
|
|
1267
|
+
var ARTIFACT_REVIEW_DECISIONS = Object.freeze([
|
|
1268
|
+
"pending",
|
|
1269
|
+
"approved",
|
|
1270
|
+
"changes_requested"
|
|
1271
|
+
]);
|
|
1272
|
+
var ARTIFACT_REVIEW_INTENTS = Object.freeze(["fix", "improve", "question"]);
|
|
1273
|
+
var ARTIFACT_REVIEW_STATUSES = Object.freeze(["open", "addressed", "resolved"]);
|
|
1274
|
+
var REVIEW_OF_RE = /^[a-f0-9]{64}$/;
|
|
1275
|
+
var ArtifactReviewStateError = class extends Error {
|
|
1276
|
+
constructor(code, message) {
|
|
1277
|
+
super(message);
|
|
1278
|
+
this.name = "ArtifactReviewStateError";
|
|
1279
|
+
this.code = code;
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
function invalid(message) {
|
|
1283
|
+
throw new ArtifactReviewStateError("E_ARTIFACT_REVIEW_INVALID", message);
|
|
1284
|
+
}
|
|
1285
|
+
function identityRequired() {
|
|
1286
|
+
throw new ArtifactReviewStateError(
|
|
1287
|
+
"E_ARTIFACT_REVIEW_IDENTITY_REQUIRED",
|
|
1288
|
+
"Enter your name before adding a comment."
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
function boundedString(value, label, { min = 0, max, trim = false, pattern } = {}) {
|
|
1292
|
+
if (typeof value !== "string") invalid(`${label} must be a string.`);
|
|
1293
|
+
const normalized = trim ? value.trim() : value;
|
|
1294
|
+
if (normalized.length < min || max !== void 0 && normalized.length > max) {
|
|
1295
|
+
invalid(`${label} must contain ${min} through ${max ?? "unlimited"} characters.`);
|
|
1296
|
+
}
|
|
1297
|
+
if (pattern && !pattern.test(normalized)) invalid(`${label} has an invalid format.`);
|
|
1298
|
+
return normalized;
|
|
1299
|
+
}
|
|
1300
|
+
function optionalString(value, label, options) {
|
|
1301
|
+
if (value === void 0) return void 0;
|
|
1302
|
+
return boundedString(value, label, options);
|
|
1303
|
+
}
|
|
1304
|
+
function enumValue(value, values, label) {
|
|
1305
|
+
if (!values.includes(value)) invalid(`${label} must be one of: ${values.join(", ")}.`);
|
|
1306
|
+
return value;
|
|
1307
|
+
}
|
|
1308
|
+
function isoTimestamp(value, label) {
|
|
1309
|
+
const timestamp = value instanceof Date ? value.toISOString() : value;
|
|
1310
|
+
if (typeof timestamp !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) {
|
|
1311
|
+
invalid(`${label} must be an ISO-8601 date-time.`);
|
|
1312
|
+
}
|
|
1313
|
+
return timestamp;
|
|
1314
|
+
}
|
|
1315
|
+
function normalizeArtifactReviewIdentity(value, { allowEmpty = false } = {}) {
|
|
1316
|
+
if (value === null || value === void 0 || value === "") {
|
|
1317
|
+
if (allowEmpty) return null;
|
|
1318
|
+
identityRequired();
|
|
1319
|
+
}
|
|
1320
|
+
const source = typeof value === "string" ? { name: value } : value;
|
|
1321
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) identityRequired();
|
|
1322
|
+
const name = boundedString(source.name, "author.name", {
|
|
1323
|
+
min: 1,
|
|
1324
|
+
max: ARTIFACT_REVIEW_LIMITS.authorName,
|
|
1325
|
+
trim: true
|
|
1326
|
+
});
|
|
1327
|
+
const id = optionalString(source.id, "author.id", {
|
|
1328
|
+
min: 1,
|
|
1329
|
+
max: ARTIFACT_REVIEW_LIMITS.id,
|
|
1330
|
+
trim: true
|
|
1331
|
+
});
|
|
1332
|
+
return deepFreeze(id === void 0 ? { name } : { id, name });
|
|
1333
|
+
}
|
|
1334
|
+
function normalizeRegion(region) {
|
|
1335
|
+
if (!region || typeof region !== "object" || Array.isArray(region)) {
|
|
1336
|
+
invalid("pin.region must be an object.");
|
|
1337
|
+
}
|
|
1338
|
+
const unit = (value, label) => {
|
|
1339
|
+
if (typeof value !== "number" || !Number.isFinite(value)) invalid(`${label} must be finite.`);
|
|
1340
|
+
return Math.round(Math.min(1, Math.max(0, value)) * 1e6) / 1e6;
|
|
1341
|
+
};
|
|
1342
|
+
const x = unit(region.x, "pin.region.x");
|
|
1343
|
+
const y = unit(region.y, "pin.region.y");
|
|
1344
|
+
const w = Math.round(Math.min(unit(region.w, "pin.region.w"), 1 - x) * 1e6) / 1e6;
|
|
1345
|
+
const h = Math.round(Math.min(unit(region.h, "pin.region.h"), 1 - y) * 1e6) / 1e6;
|
|
1346
|
+
return { x, y, w, h };
|
|
1347
|
+
}
|
|
1348
|
+
function normalizeViewport(viewport) {
|
|
1349
|
+
if (!viewport || typeof viewport !== "object" || Array.isArray(viewport)) {
|
|
1350
|
+
invalid("pin.viewport must be an object.");
|
|
1351
|
+
}
|
|
1352
|
+
const dimension = (value, label) => {
|
|
1353
|
+
if (!Number.isInteger(value) || value < 1 || value > ARTIFACT_REVIEW_LIMITS.viewport) {
|
|
1354
|
+
invalid(`${label} must be an integer from 1 through ${ARTIFACT_REVIEW_LIMITS.viewport}.`);
|
|
1355
|
+
}
|
|
1356
|
+
return value;
|
|
1357
|
+
};
|
|
1358
|
+
return {
|
|
1359
|
+
width: dimension(viewport.width, "pin.viewport.width"),
|
|
1360
|
+
height: dimension(viewport.height, "pin.viewport.height")
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
function normalizeAnchor(anchor) {
|
|
1364
|
+
if (anchor === void 0 || anchor === null) return void 0;
|
|
1365
|
+
if (typeof anchor !== "object" || Array.isArray(anchor)) invalid("pin.anchor must be an object.");
|
|
1366
|
+
const planrId = boundedString(anchor.planrId, "pin.anchor.planrId", {
|
|
1367
|
+
min: 1,
|
|
1368
|
+
max: ARTIFACT_REVIEW_LIMITS.anchor,
|
|
1369
|
+
trim: true
|
|
1370
|
+
});
|
|
1371
|
+
const screen = optionalString(anchor.screen, "pin.anchor.screen", {
|
|
1372
|
+
min: 1,
|
|
1373
|
+
max: ARTIFACT_REVIEW_LIMITS.screen,
|
|
1374
|
+
trim: true
|
|
1375
|
+
});
|
|
1376
|
+
return screen === void 0 ? { planrId } : { planrId, screen };
|
|
1377
|
+
}
|
|
1378
|
+
function normalizeReply(reply, label = "reply") {
|
|
1379
|
+
if (!reply || typeof reply !== "object" || Array.isArray(reply))
|
|
1380
|
+
invalid(`${label} must be an object.`);
|
|
1381
|
+
return {
|
|
1382
|
+
id: boundedString(reply.id, `${label}.id`, {
|
|
1383
|
+
min: 1,
|
|
1384
|
+
max: ARTIFACT_REVIEW_LIMITS.id,
|
|
1385
|
+
trim: true
|
|
1386
|
+
}),
|
|
1387
|
+
author: normalizeArtifactReviewIdentity(reply.author),
|
|
1388
|
+
comment: boundedString(reply.comment, `${label}.comment`, {
|
|
1389
|
+
min: 1,
|
|
1390
|
+
max: ARTIFACT_REVIEW_LIMITS.text,
|
|
1391
|
+
trim: true
|
|
1392
|
+
}),
|
|
1393
|
+
createdAt: isoTimestamp(reply.createdAt, `${label}.createdAt`)
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function compareTimestampThenId(left, right) {
|
|
1397
|
+
const byTime = left.createdAt.localeCompare(right.createdAt);
|
|
1398
|
+
return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
|
|
1399
|
+
}
|
|
1400
|
+
function normalizePin(pin, label = "pin") {
|
|
1401
|
+
if (!pin || typeof pin !== "object" || Array.isArray(pin)) invalid(`${label} must be an object.`);
|
|
1402
|
+
if (!Array.isArray(pin.replies) || pin.replies.length > ARTIFACT_REVIEW_LIMITS.replies) {
|
|
1403
|
+
invalid(`${label}.replies must contain no more than ${ARTIFACT_REVIEW_LIMITS.replies} items.`);
|
|
1404
|
+
}
|
|
1405
|
+
const replies = pin.replies.map(
|
|
1406
|
+
(reply, index) => normalizeReply(reply, `${label}.replies[${index}]`)
|
|
1407
|
+
);
|
|
1408
|
+
const replyIds = new Set(replies.map(({ id }) => id));
|
|
1409
|
+
if (replyIds.size !== replies.length) invalid(`${label}.replies must have unique ids.`);
|
|
1410
|
+
const normalized = {
|
|
1411
|
+
id: boundedString(pin.id, `${label}.id`, {
|
|
1412
|
+
min: 1,
|
|
1413
|
+
max: ARTIFACT_REVIEW_LIMITS.id,
|
|
1414
|
+
trim: true
|
|
1415
|
+
}),
|
|
1416
|
+
author: normalizeArtifactReviewIdentity(pin.author),
|
|
1417
|
+
artifactId: boundedString(pin.artifactId, `${label}.artifactId`, {
|
|
1418
|
+
min: 1,
|
|
1419
|
+
max: ARTIFACT_REVIEW_LIMITS.artifactId,
|
|
1420
|
+
trim: true
|
|
1421
|
+
}),
|
|
1422
|
+
region: normalizeRegion(pin.region),
|
|
1423
|
+
viewport: normalizeViewport(pin.viewport),
|
|
1424
|
+
intent: enumValue(pin.intent, ARTIFACT_REVIEW_INTENTS, `${label}.intent`),
|
|
1425
|
+
status: enumValue(pin.status, ARTIFACT_REVIEW_STATUSES, `${label}.status`),
|
|
1426
|
+
comment: boundedString(pin.comment, `${label}.comment`, {
|
|
1427
|
+
min: 1,
|
|
1428
|
+
max: ARTIFACT_REVIEW_LIMITS.text,
|
|
1429
|
+
trim: true
|
|
1430
|
+
}),
|
|
1431
|
+
replies: replies.sort(compareTimestampThenId),
|
|
1432
|
+
createdAt: isoTimestamp(pin.createdAt, `${label}.createdAt`),
|
|
1433
|
+
updatedAt: isoTimestamp(pin.updatedAt, `${label}.updatedAt`)
|
|
1434
|
+
};
|
|
1435
|
+
const variant = optionalString(pin.variant, `${label}.variant`, {
|
|
1436
|
+
min: 1,
|
|
1437
|
+
max: ARTIFACT_REVIEW_LIMITS.variant,
|
|
1438
|
+
trim: true
|
|
1439
|
+
});
|
|
1440
|
+
const anchor = normalizeAnchor(pin.anchor);
|
|
1441
|
+
if (variant !== void 0) normalized.variant = variant;
|
|
1442
|
+
if (anchor !== void 0) normalized.anchor = anchor;
|
|
1443
|
+
return normalized;
|
|
1444
|
+
}
|
|
1445
|
+
function normalizeArtifactReview2(review) {
|
|
1446
|
+
if (!review || typeof review !== "object" || Array.isArray(review)) {
|
|
1447
|
+
invalid("Artifact review must be an object.");
|
|
1448
|
+
}
|
|
1449
|
+
if (!Array.isArray(review.pins) || review.pins.length > ARTIFACT_REVIEW_LIMITS.pins) {
|
|
1450
|
+
invalid(`review.pins must contain no more than ${ARTIFACT_REVIEW_LIMITS.pins} items.`);
|
|
1451
|
+
}
|
|
1452
|
+
const pins = review.pins.map((pin, index) => normalizePin(pin, `review.pins[${index}]`));
|
|
1453
|
+
const pinIds = new Set(pins.map(({ id }) => id));
|
|
1454
|
+
if (pinIds.size !== pins.length) invalid("review.pins must have unique ids.");
|
|
1455
|
+
const normalized = {
|
|
1456
|
+
schemaVersion: enumValue(review.schemaVersion, ["1.0.0"], "review.schemaVersion"),
|
|
1457
|
+
reviewId: boundedString(review.reviewId, "review.reviewId", {
|
|
1458
|
+
min: 1,
|
|
1459
|
+
max: ARTIFACT_REVIEW_LIMITS.id,
|
|
1460
|
+
trim: true
|
|
1461
|
+
}),
|
|
1462
|
+
reviewOf: boundedString(review.reviewOf, "review.reviewOf", {
|
|
1463
|
+
min: 64,
|
|
1464
|
+
max: 64,
|
|
1465
|
+
pattern: REVIEW_OF_RE
|
|
1466
|
+
}),
|
|
1467
|
+
decision: enumValue(review.decision, ARTIFACT_REVIEW_DECISIONS, "review.decision"),
|
|
1468
|
+
overall: boundedString(review.overall, "review.overall", {
|
|
1469
|
+
max: ARTIFACT_REVIEW_LIMITS.text
|
|
1470
|
+
}),
|
|
1471
|
+
pins: pins.sort(compareTimestampThenId)
|
|
1472
|
+
};
|
|
1473
|
+
if (review.createdAt !== void 0)
|
|
1474
|
+
normalized.createdAt = isoTimestamp(review.createdAt, "review.createdAt");
|
|
1475
|
+
if (review.updatedAt !== void 0)
|
|
1476
|
+
normalized.updatedAt = isoTimestamp(review.updatedAt, "review.updatedAt");
|
|
1477
|
+
return deepFreeze(normalized);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// packages/design/lib/design/workspace-feedback.mjs
|
|
1481
|
+
function workspaceReviewerId(publicKey) {
|
|
1482
|
+
return sha256Hex(
|
|
1483
|
+
canonicalizeJson({ kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, y: publicKey.y })
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
function mergeWorkspaceFeedback(events, { revisionId, reviewOf, ownerPublicKey } = {}) {
|
|
1487
|
+
if (!Array.isArray(events) || !revisionId || !/^[a-f0-9]{64}$/u.test(reviewOf))
|
|
1488
|
+
throw new TypeError("Feedback merge requires a revision and content digest.");
|
|
1489
|
+
const pins = /* @__PURE__ */ new Map();
|
|
1490
|
+
const pinOwners = /* @__PURE__ */ new Map();
|
|
1491
|
+
const replyOwners = /* @__PURE__ */ new Map();
|
|
1492
|
+
const overall = /* @__PURE__ */ new Map();
|
|
1493
|
+
const directions = /* @__PURE__ */ new Map();
|
|
1494
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1495
|
+
const revisionBases = /* @__PURE__ */ new Map();
|
|
1496
|
+
const acceptedEventIds = [];
|
|
1497
|
+
const issues = [];
|
|
1498
|
+
const categories = {}, dispositions = {};
|
|
1499
|
+
for (const event of [...events].sort((a, b) => a.sequence - b.sequence)) {
|
|
1500
|
+
try {
|
|
1501
|
+
if (!event || typeof event.id !== "string" || !event.id || typeof event.revisionId !== "string" || !/^[a-f0-9]{64}$/u.test(event.reviewOf ?? "") || !Number.isSafeInteger(event.sequence) || event.sequence < 1)
|
|
1502
|
+
throw new TypeError("Shared feedback has an invalid event identity or revision.");
|
|
1503
|
+
const eventHash = sha256Hex(canonicalizeJson(event));
|
|
1504
|
+
if (seen.has(event.id)) {
|
|
1505
|
+
if (seen.get(event.id) !== eventHash)
|
|
1506
|
+
throw new TypeError("Shared feedback reuses an event identity with changed bytes.");
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
seen.set(event.id, eventHash);
|
|
1510
|
+
const boundReviewOf = revisionBases.get(event.revisionId);
|
|
1511
|
+
if (boundReviewOf && boundReviewOf !== event.reviewOf)
|
|
1512
|
+
throw new TypeError("A shared revision identity is bound to conflicting design content.");
|
|
1513
|
+
revisionBases.set(event.revisionId, event.reviewOf);
|
|
1514
|
+
if (event.revisionId !== revisionId) continue;
|
|
1515
|
+
if (event.reviewOf !== reviewOf || !event.publicKey?.x || !event.publicKey?.y)
|
|
1516
|
+
throw new TypeError("Shared feedback has an invalid revision or signer.");
|
|
1517
|
+
const signerId = workspaceReviewerId(event.publicKey);
|
|
1518
|
+
const payload = event.payload;
|
|
1519
|
+
if (!payload || typeof payload.author !== "string" || !payload.author.trim() || payload.author.length > 160 || payload.reviewOf !== reviewOf)
|
|
1520
|
+
throw new TypeError("Shared feedback has an invalid author or digest.");
|
|
1521
|
+
const author = { id: signerId, name: payload.author.trim() };
|
|
1522
|
+
if (["category", "disposition"].includes(payload.kind)) {
|
|
1523
|
+
assertDesignReviewMetadata(payload);
|
|
1524
|
+
if (!pins.has(payload.pinId))
|
|
1525
|
+
throw new TypeError("Review metadata targets an unknown pin.");
|
|
1526
|
+
const owner = ownerPublicKey?.x === event.publicKey.x && ownerPublicKey?.y === event.publicKey.y;
|
|
1527
|
+
if (payload.kind === "disposition" && !owner)
|
|
1528
|
+
throw new TypeError("Only the design owner can record a disposition.");
|
|
1529
|
+
if (payload.kind === "category" && !owner && pinOwners.get(payload.pinId) !== signerId)
|
|
1530
|
+
throw new TypeError("Only the comment author or owner can change its category.");
|
|
1531
|
+
if (payload.kind === "category") categories[payload.pinId] = payload.category;
|
|
1532
|
+
else
|
|
1533
|
+
dispositions[payload.pinId] = {
|
|
1534
|
+
disposition: payload.disposition,
|
|
1535
|
+
reason: payload.reason,
|
|
1536
|
+
updatedAt: payload.updatedAt,
|
|
1537
|
+
author: author.name
|
|
1538
|
+
};
|
|
1539
|
+
acceptedEventIds.push(event.id);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
if (payload.kind === "direction") {
|
|
1543
|
+
for (const value of [payload.ratings ?? {}, payload.remix ?? {}])
|
|
1544
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 256)
|
|
1545
|
+
throw new TypeError("Shared direction feedback is too large or invalid.");
|
|
1546
|
+
const ratings = {};
|
|
1547
|
+
const remix = {};
|
|
1548
|
+
for (const [id, rating] of Object.entries(payload.ratings ?? {})) {
|
|
1549
|
+
if (Number.isInteger(rating) && rating >= 1 && rating <= 5) ratings[id] = rating;
|
|
1550
|
+
}
|
|
1551
|
+
for (const [id, note] of Object.entries(payload.remix ?? {})) {
|
|
1552
|
+
if (typeof note === "string" && note.length <= 8192) remix[id] = note;
|
|
1553
|
+
}
|
|
1554
|
+
directions.set(signerId, { signerId, author: author.name, revisionId, ratings, remix });
|
|
1555
|
+
acceptedEventIds.push(event.id);
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (payload.kind !== "review" || payload.review?.reviewOf !== reviewOf)
|
|
1559
|
+
throw new TypeError("Shared review snapshot does not match its signed revision.");
|
|
1560
|
+
const review2 = normalizeArtifactReview2(payload.review);
|
|
1561
|
+
const additions = review2.pins.filter((pin) => !pins.has(pin.id)).length;
|
|
1562
|
+
if (pins.size + additions > ARTIFACT_REVIEW_LIMITS.pins)
|
|
1563
|
+
throw new TypeError("Shared feedback exceeds the total pin limit.");
|
|
1564
|
+
for (const incoming of review2.pins) {
|
|
1565
|
+
const previous = pins.get(incoming.id);
|
|
1566
|
+
const replies = /* @__PURE__ */ new Set([
|
|
1567
|
+
...(previous?.replies ?? []).map((reply) => reply.id),
|
|
1568
|
+
...incoming.replies.map((reply) => reply.id)
|
|
1569
|
+
]);
|
|
1570
|
+
if (replies.size > ARTIFACT_REVIEW_LIMITS.replies)
|
|
1571
|
+
throw new TypeError("Shared feedback exceeds the thread reply limit.");
|
|
1572
|
+
}
|
|
1573
|
+
overall.set(signerId, { author: author.name, note: review2.overall });
|
|
1574
|
+
for (const incoming of review2.pins) {
|
|
1575
|
+
const previous = pins.get(incoming.id);
|
|
1576
|
+
if (!previous) {
|
|
1577
|
+
pins.set(incoming.id, { ...structuredClone(incoming), author, replies: [] });
|
|
1578
|
+
pinOwners.set(incoming.id, signerId);
|
|
1579
|
+
} else if (pinOwners.get(incoming.id) === signerId) {
|
|
1580
|
+
pins.set(incoming.id, {
|
|
1581
|
+
...previous,
|
|
1582
|
+
comment: incoming.comment,
|
|
1583
|
+
status: incoming.status,
|
|
1584
|
+
updatedAt: incoming.updatedAt,
|
|
1585
|
+
author,
|
|
1586
|
+
replies: previous.replies
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
const pin = pins.get(incoming.id);
|
|
1590
|
+
for (const reply of incoming.replies) {
|
|
1591
|
+
const key = `${incoming.id}:${reply.id}`;
|
|
1592
|
+
if (!replyOwners.has(key)) {
|
|
1593
|
+
replyOwners.set(key, signerId);
|
|
1594
|
+
pin.replies.push({ ...structuredClone(reply), author });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
acceptedEventIds.push(event.id);
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
issues.push({ eventId: event.id, sequence: event.sequence, reason: error.message });
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const review = normalizeArtifactReview2({
|
|
1604
|
+
schemaVersion: "1.0.0",
|
|
1605
|
+
reviewId: `shared-${revisionId}`,
|
|
1606
|
+
reviewOf,
|
|
1607
|
+
decision: "pending",
|
|
1608
|
+
overall: [...overall.values()].filter(({ note }) => note).map(({ author, note }) => `${author}: ${note}`).join("\n\n").slice(0, 16384),
|
|
1609
|
+
pins: [...pins.values()]
|
|
1610
|
+
});
|
|
1611
|
+
return {
|
|
1612
|
+
review,
|
|
1613
|
+
directions: [...directions.values()],
|
|
1614
|
+
metadata: { categories, dispositions },
|
|
1615
|
+
acceptedEventIds,
|
|
1616
|
+
issues
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// packages/design/lib/design/share.mjs
|
|
1621
|
+
var FORMAT = "openplanr-design-owner-custody";
|
|
1622
|
+
function prepareDesignShareBundle(file) {
|
|
1623
|
+
const current = currentDesign(file);
|
|
1624
|
+
const saved = readJson(join2(current.root, ".design/studio-state.json"), { state: {} }).state;
|
|
1625
|
+
return bundleDesignRevision(current, saved);
|
|
1626
|
+
}
|
|
1627
|
+
function custodyLocation(file, options = {}, { allowMissing = false } = {}) {
|
|
1628
|
+
const current = currentDesign(file);
|
|
1629
|
+
const env = options.env ?? process.env;
|
|
1630
|
+
const root = resolve2(
|
|
1631
|
+
options.custodyRoot ?? join2(
|
|
1632
|
+
configuredPlanrHome(env) ?? join2(realpathSync2(env.HOME || homedir2()), ".openplanr"),
|
|
1633
|
+
"design-shares"
|
|
1634
|
+
)
|
|
1635
|
+
);
|
|
1636
|
+
let project = current.root;
|
|
1637
|
+
for (let candidate = current.root; dirname3(candidate) !== candidate; candidate = dirname3(candidate)) {
|
|
1638
|
+
if (existsSync3(join2(candidate, ".git")) || existsSync3(join2(candidate, ".planr"))) {
|
|
1639
|
+
project = candidate;
|
|
1640
|
+
break;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const key = hash(`${current.root}
|
|
1644
|
+
${current.document.id}`);
|
|
1645
|
+
const path = join2(root, `${key}.json`);
|
|
1646
|
+
const within = relative2(project, root);
|
|
1647
|
+
if ((!allowMissing || existsSync3(path)) && (within === "" || !within.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && within !== ".." && !isAbsolute2(within)))
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
"Design owner credentials must be stored outside the project. Set PLANR_HOME to a private user-level directory."
|
|
1650
|
+
);
|
|
1651
|
+
return { root, path, current };
|
|
1652
|
+
}
|
|
1653
|
+
function ensurePrivateDirectory(root) {
|
|
1654
|
+
for (let path = root; dirname3(path) !== path; path = dirname3(path)) {
|
|
1655
|
+
if (existsSync3(path) && lstatSync3(path).isSymbolicLink())
|
|
1656
|
+
throw new Error("Design custody directory must not contain symbolic links.");
|
|
1657
|
+
}
|
|
1658
|
+
mkdirSync3(root, { recursive: true, mode: 448 });
|
|
1659
|
+
const stat = lstatSync3(root);
|
|
1660
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
1661
|
+
throw new Error("Design custody must use a private local directory.");
|
|
1662
|
+
if (process.platform !== "win32") chmodSync(root, 448);
|
|
1663
|
+
}
|
|
1664
|
+
function readCustody(path) {
|
|
1665
|
+
if (!existsSync3(path)) return null;
|
|
1666
|
+
const stat = lstatSync3(path);
|
|
1667
|
+
if (!stat.isFile() || stat.isSymbolicLink() || process.platform !== "win32" && stat.mode & 63)
|
|
1668
|
+
throw new Error("Design owner custody must be a private 0600 file.");
|
|
1669
|
+
const record = JSON.parse(readFileSync3(path, "utf8"));
|
|
1670
|
+
if (record.kind !== FORMAT || record.schemaVersion !== "1.0.0" || !record.custody)
|
|
1671
|
+
throw new Error("Design owner custody is invalid.");
|
|
1672
|
+
return record;
|
|
1673
|
+
}
|
|
1674
|
+
function writeCustody(path, record) {
|
|
1675
|
+
const temp = `${path}.${randomBytes2(8).toString("hex")}.tmp`;
|
|
1676
|
+
const fd = openSync(temp, "wx", 384);
|
|
1677
|
+
try {
|
|
1678
|
+
writeFileSync3(fd, `${JSON.stringify(record)}
|
|
1679
|
+
`);
|
|
1680
|
+
fsyncSync(fd);
|
|
1681
|
+
} finally {
|
|
1682
|
+
closeSync(fd);
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
renameSync3(temp, path);
|
|
1686
|
+
if (process.platform !== "win32") {
|
|
1687
|
+
chmodSync(path, 384);
|
|
1688
|
+
const directory = openSync(dirname3(path), "r");
|
|
1689
|
+
try {
|
|
1690
|
+
fsyncSync(directory);
|
|
1691
|
+
} finally {
|
|
1692
|
+
closeSync(directory);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
try {
|
|
1697
|
+
unlinkSync(temp);
|
|
1698
|
+
} catch {
|
|
1699
|
+
}
|
|
1700
|
+
throw error;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
async function withCustody(file, options, action) {
|
|
1704
|
+
const location = custodyLocation(file, options);
|
|
1705
|
+
ensurePrivateDirectory(location.root);
|
|
1706
|
+
const unlock = await acquireStartLock(`${location.path}.lock`);
|
|
1707
|
+
try {
|
|
1708
|
+
let record = readCustody(location.path);
|
|
1709
|
+
return await action({
|
|
1710
|
+
...location,
|
|
1711
|
+
record,
|
|
1712
|
+
save(value = record) {
|
|
1713
|
+
record = value;
|
|
1714
|
+
writeCustody(location.path, value);
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
} finally {
|
|
1718
|
+
unlock();
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async function commitMutation(record, save, options) {
|
|
1722
|
+
try {
|
|
1723
|
+
return await commitWorkspaceMutation(record.custody, {
|
|
1724
|
+
fetchImpl: options.fetchImpl
|
|
1725
|
+
});
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
if (error.status === 409 && record.custody.pendingMutation) {
|
|
1728
|
+
const pending = structuredClone(record.custody.pendingMutation);
|
|
1729
|
+
try {
|
|
1730
|
+
const remote = await getWorkspace(record.custody, {
|
|
1731
|
+
fetchImpl: options.fetchImpl
|
|
1732
|
+
});
|
|
1733
|
+
if (remote.version > pending.body.expectedVersion) {
|
|
1734
|
+
record.conflictedMutation = { ...pending, localRevision: record.pendingRevision ?? null };
|
|
1735
|
+
delete record.custody.pendingMutation;
|
|
1736
|
+
delete record.pendingRevision;
|
|
1737
|
+
save(record);
|
|
1738
|
+
}
|
|
1739
|
+
} catch {
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
throw error;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
function presentationFingerprint(current) {
|
|
1746
|
+
const state = readJson(join2(current.root, ".design/studio-state.json"), { state: {} }).state;
|
|
1747
|
+
return hash(
|
|
1748
|
+
JSON.stringify({
|
|
1749
|
+
revision: current.revision,
|
|
1750
|
+
selectedVariant: state.selectedVariant ?? current.document.selectedVariant,
|
|
1751
|
+
positions: state.positions ?? {},
|
|
1752
|
+
verification: current.verification.status
|
|
1753
|
+
})
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
var safeStatus = (record, current) => ({
|
|
1757
|
+
ok: true,
|
|
1758
|
+
shared: Boolean(record),
|
|
1759
|
+
title: current.document.title,
|
|
1760
|
+
localRevision: current.revision,
|
|
1761
|
+
retention: "until-revoked",
|
|
1762
|
+
...record ? {
|
|
1763
|
+
id: record.custody.id,
|
|
1764
|
+
url: `${record.custody.baseUrl}/d/${encodeURIComponent(record.custody.id)}`,
|
|
1765
|
+
revision: record.custody.currentRevision ?? record.publishedRevision ?? null,
|
|
1766
|
+
publishedRevision: record.publishedRevision ?? null,
|
|
1767
|
+
hasUpdate: record.publishedRevision !== current.revision || record.publishedPresentation !== presentationFingerprint(current),
|
|
1768
|
+
epoch: record.custody.epoch,
|
|
1769
|
+
commentsPaused: Boolean(record.custody.commentsPaused ?? record.commentsPaused),
|
|
1770
|
+
revoked: Boolean(record.revoked),
|
|
1771
|
+
deleted: Boolean(record.deleted),
|
|
1772
|
+
pending: Boolean(
|
|
1773
|
+
record.custody.pendingCreate || record.custody.pendingMutation || record.pendingReviewMetadata?.length
|
|
1774
|
+
),
|
|
1775
|
+
pendingAction: record.custody.pendingCreate ? "create" : record.custody.pendingMutation?.action ?? (record.pendingReviewMetadata?.length ? "review-metadata" : null),
|
|
1776
|
+
pendingReviewMetadata: Boolean(record.pendingReviewMetadata?.length)
|
|
1777
|
+
} : {}
|
|
1778
|
+
});
|
|
1779
|
+
function getDesignShareStatus(file, options = {}) {
|
|
1780
|
+
const { path, current } = custodyLocation(file, options);
|
|
1781
|
+
return safeStatus(readCustody(path), current);
|
|
1782
|
+
}
|
|
1783
|
+
async function shareDesign(file, options = {}) {
|
|
1784
|
+
return withCustody(file, options, async ({ record, current, save }) => {
|
|
1785
|
+
if (record?.deleted)
|
|
1786
|
+
throw new Error(
|
|
1787
|
+
"This shared review was deleted. Create a new design identity to share a new review."
|
|
1788
|
+
);
|
|
1789
|
+
if (!record) {
|
|
1790
|
+
const custody = await prepareWorkspace(prepareDesignShareBundle(file), {
|
|
1791
|
+
baseUrl: options.baseUrl ?? options.env?.OPENPLANR_SHARE_BASE ?? process.env.OPENPLANR_SHARE_BASE ?? "https://share.openplanr.dev"
|
|
1792
|
+
});
|
|
1793
|
+
record = {
|
|
1794
|
+
schemaVersion: "1.0.0",
|
|
1795
|
+
kind: FORMAT,
|
|
1796
|
+
designId: current.document.id,
|
|
1797
|
+
custody,
|
|
1798
|
+
publishedRevision: null,
|
|
1799
|
+
pendingRevision: current.revision,
|
|
1800
|
+
pendingPresentation: presentationFingerprint(current),
|
|
1801
|
+
lastEvent: 0
|
|
1802
|
+
};
|
|
1803
|
+
save(record);
|
|
1804
|
+
}
|
|
1805
|
+
if (record.custody.pendingCreate) {
|
|
1806
|
+
await commitWorkspace(record.custody, { fetchImpl: options.fetchImpl });
|
|
1807
|
+
record.publishedRevision = record.pendingRevision;
|
|
1808
|
+
record.publishedPresentation = record.pendingPresentation;
|
|
1809
|
+
delete record.pendingRevision;
|
|
1810
|
+
delete record.pendingPresentation;
|
|
1811
|
+
save(record);
|
|
1812
|
+
}
|
|
1813
|
+
return safeStatus(record, current);
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
async function publishDesignShare(file, options = {}) {
|
|
1817
|
+
return withCustody(file, options, async ({ record, current, save }) => {
|
|
1818
|
+
if (!record || record.custody.pendingCreate)
|
|
1819
|
+
throw new Error("Create the shared review before publishing an update.");
|
|
1820
|
+
if (record.deleted || record.revoked)
|
|
1821
|
+
throw new Error("Access was revoked or the review was deleted.");
|
|
1822
|
+
if (record.custody.pendingMutation && record.custody.pendingMutation.action !== "publish")
|
|
1823
|
+
throw new Error(
|
|
1824
|
+
`Retry the pending ${record.custody.pendingMutation.action} operation first.`
|
|
1825
|
+
);
|
|
1826
|
+
if (!record.custody.pendingMutation) {
|
|
1827
|
+
await getWorkspace(record.custody, { fetchImpl: options.fetchImpl });
|
|
1828
|
+
await prepareWorkspaceMutation(
|
|
1829
|
+
record.custody,
|
|
1830
|
+
"publish",
|
|
1831
|
+
prepareDesignShareBundle(file)
|
|
1832
|
+
);
|
|
1833
|
+
record.pendingRevision = current.revision;
|
|
1834
|
+
record.pendingPresentation = presentationFingerprint(current);
|
|
1835
|
+
save(record);
|
|
1836
|
+
}
|
|
1837
|
+
await commitMutation(record, save, options);
|
|
1838
|
+
record.publishedRevision = record.pendingRevision;
|
|
1839
|
+
record.publishedPresentation = record.pendingPresentation;
|
|
1840
|
+
delete record.pendingRevision;
|
|
1841
|
+
delete record.pendingPresentation;
|
|
1842
|
+
save(record);
|
|
1843
|
+
return safeStatus(record, current);
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
async function manageDesignShare(file, action, options = {}) {
|
|
1847
|
+
if (!["rotate", "pause", "resume", "revoke", "delete", "access"].includes(action))
|
|
1848
|
+
throw new Error("Unknown design sharing action.");
|
|
1849
|
+
return withCustody(file, options, async ({ record, current, save }) => {
|
|
1850
|
+
if (!record || record.custody.pendingCreate) throw new Error("Create the shared review first.");
|
|
1851
|
+
if (action === "access") {
|
|
1852
|
+
if (record.revoked || record.deleted) throw new Error("This review is no longer accessible.");
|
|
1853
|
+
return { ...safeStatus(record, current), token: record.custody.token };
|
|
1854
|
+
}
|
|
1855
|
+
if (action === "rotate") await flushOwnerMetadata(record, save, options);
|
|
1856
|
+
if (record.custody.pendingMutation && record.custody.pendingMutation.action !== action)
|
|
1857
|
+
throw new Error(
|
|
1858
|
+
`Retry the pending ${record.custody.pendingMutation.action} operation first.`
|
|
1859
|
+
);
|
|
1860
|
+
if (!record.custody.pendingMutation) {
|
|
1861
|
+
if (!record.revoked)
|
|
1862
|
+
await getWorkspace(record.custody, { fetchImpl: options.fetchImpl });
|
|
1863
|
+
await prepareWorkspaceMutation(record.custody, action);
|
|
1864
|
+
save(record);
|
|
1865
|
+
}
|
|
1866
|
+
await commitMutation(record, save, options);
|
|
1867
|
+
if (action === "pause" || action === "resume") record.commentsPaused = action === "pause";
|
|
1868
|
+
if (action === "revoke") record.revoked = true;
|
|
1869
|
+
if (action === "delete") record.deleted = true;
|
|
1870
|
+
save(record);
|
|
1871
|
+
return safeStatus(record, current);
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
async function flushOwnerMetadata(record, save, options) {
|
|
1875
|
+
while (record.pendingReviewMetadata?.length) {
|
|
1876
|
+
await appendWorkspaceEvent(record.custody, null, {
|
|
1877
|
+
preparedEvent: record.pendingReviewMetadata[0],
|
|
1878
|
+
fetchImpl: options.fetchImpl
|
|
1879
|
+
});
|
|
1880
|
+
record.pendingReviewMetadata.shift();
|
|
1881
|
+
save(record);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
async function publishDesignReviewMetadata(file, payload, { revisionId, ...options } = {}) {
|
|
1885
|
+
const location = custodyLocation(file, options, { allowMissing: true });
|
|
1886
|
+
if (!existsSync3(location.path) || !revisionId) return { shared: false };
|
|
1887
|
+
return withCustody(file, options, async ({ record, save }) => {
|
|
1888
|
+
if (!record || record.deleted || record.revoked || record.custody.pendingCreate)
|
|
1889
|
+
return { shared: false };
|
|
1890
|
+
const event = await prepareWorkspaceEvent(record.custody, payload, {
|
|
1891
|
+
revisionId,
|
|
1892
|
+
reviewOf: payload.reviewOf,
|
|
1893
|
+
signer: {
|
|
1894
|
+
privateKey: record.custody.ownerPrivateKey,
|
|
1895
|
+
publicKey: record.custody.ownerPublicKey
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
record.pendingReviewMetadata ??= [];
|
|
1899
|
+
record.pendingReviewMetadata.push(event);
|
|
1900
|
+
save(record);
|
|
1901
|
+
try {
|
|
1902
|
+
await flushOwnerMetadata(record, save, options);
|
|
1903
|
+
return { shared: true, pending: false };
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
return { shared: true, pending: true, error: error.message };
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
async function exportDesignShareRecovery(file, { output, ...options } = {}) {
|
|
1910
|
+
if (!output) throw new Error("Recovery export requires a new private output path.");
|
|
1911
|
+
return withCustody(file, options, async ({ record }) => {
|
|
1912
|
+
if (!record) throw new Error("Create the shared review first.");
|
|
1913
|
+
const target = resolve2(output);
|
|
1914
|
+
mkdirSync3(dirname3(target), { recursive: true, mode: 448 });
|
|
1915
|
+
writeFileSync3(target, `${JSON.stringify(record, null, 2)}
|
|
1916
|
+
`, { flag: "wx", mode: 384 });
|
|
1917
|
+
return { ok: true, output: target };
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
async function importDesignShareRecovery(file, { input, ...options } = {}) {
|
|
1921
|
+
if (!input) throw new Error("Recovery restore requires --input <private recovery file>.");
|
|
1922
|
+
const recovered = readCustody(resolve2(input));
|
|
1923
|
+
if (!recovered) throw new Error("Recovery file could not be found.");
|
|
1924
|
+
const custody = recovered.custody;
|
|
1925
|
+
workspaceReviewUrl(custody);
|
|
1926
|
+
const proof = await signWorkspaceValue(
|
|
1927
|
+
{ recovery: custody.id, nonce: newWorkspaceId() },
|
|
1928
|
+
custody.ownerPrivateKey
|
|
1929
|
+
);
|
|
1930
|
+
if (!await verifyWorkspaceSignature(proof, custody.ownerPublicKey))
|
|
1931
|
+
throw new Error("Recovery private key does not match its owner identity.");
|
|
1932
|
+
await deriveWorkspaceAuthentication(custody.token, custody.id);
|
|
1933
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(custody.ownerAuth ?? ""))
|
|
1934
|
+
throw new Error("Recovery owner capability is invalid.");
|
|
1935
|
+
return withCustody(file, options, async ({ record, current, save }) => {
|
|
1936
|
+
if (recovered.designId !== current.document.id)
|
|
1937
|
+
throw new Error("Recovery belongs to a different design.");
|
|
1938
|
+
if (record && (record.custody.id !== custody.id || JSON.stringify(record.custody.ownerPublicKey) !== JSON.stringify(custody.ownerPublicKey)))
|
|
1939
|
+
throw new Error(
|
|
1940
|
+
"This design already has different owner credentials. Recovery will not overwrite them."
|
|
1941
|
+
);
|
|
1942
|
+
if (record && record.custody.version > custody.version)
|
|
1943
|
+
throw new Error("This recovery file is older than the locally saved owner credentials.");
|
|
1944
|
+
if (!custody.pendingCreate && !recovered.deleted && !recovered.revoked) {
|
|
1945
|
+
await getWorkspace(custody, { fetchImpl: options.fetchImpl });
|
|
1946
|
+
const bundle = await decryptWorkspaceRevision(custody, custody.currentRevision, {
|
|
1947
|
+
fetchImpl: options.fetchImpl
|
|
1948
|
+
});
|
|
1949
|
+
if (bundle.design.id !== current.document.id)
|
|
1950
|
+
throw new Error("Recovery belongs to a different design.");
|
|
1951
|
+
}
|
|
1952
|
+
recovered.lastEvent = 0;
|
|
1953
|
+
save(recovered);
|
|
1954
|
+
return { ...safeStatus(recovered, current), restored: true };
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
async function syncDesignShare(file, options = {}) {
|
|
1958
|
+
const location = custodyLocation(file, options, { allowMissing: true });
|
|
1959
|
+
if (!existsSync3(location.path)) return { ok: true, shared: false, imported: 0 };
|
|
1960
|
+
return withCustody(file, options, async ({ record, current, save }) => {
|
|
1961
|
+
if (!record || record.custody.pendingCreate || record.deleted)
|
|
1962
|
+
return { ok: true, shared: Boolean(record), imported: 0 };
|
|
1963
|
+
await flushOwnerMetadata(record, save, options);
|
|
1964
|
+
const reviewKey = `design-${hash(current.document.id).slice(0, 24)}`;
|
|
1965
|
+
const reviewPath = resolveArtifactReviewDestination({
|
|
1966
|
+
cwd: current.root,
|
|
1967
|
+
env: options.env ?? process.env,
|
|
1968
|
+
artifactId: reviewKey
|
|
1969
|
+
}).path;
|
|
1970
|
+
const currentDigest = digestArtifactEnvelope(current.envelope);
|
|
1971
|
+
let imported = 0, hasMore = true, issues = [];
|
|
1972
|
+
const ledgerPath = join2(current.root, ".design/shared-feedback.json");
|
|
1973
|
+
while (hasMore) {
|
|
1974
|
+
const page = await readWorkspaceEvents(record.custody, {
|
|
1975
|
+
after: record.lastEvent ?? 0,
|
|
1976
|
+
fetchImpl: options.fetchImpl
|
|
1977
|
+
});
|
|
1978
|
+
const earlier = readJson(ledgerPath, {
|
|
1979
|
+
schemaVersion: "1.0.0",
|
|
1980
|
+
events: [],
|
|
1981
|
+
issues: [],
|
|
1982
|
+
importedReviews: {}
|
|
1983
|
+
});
|
|
1984
|
+
const events = [...earlier.events];
|
|
1985
|
+
const eventBytes = new Map(events.map((event) => [event.id, canonicalizeJson(event)]));
|
|
1986
|
+
const revisionBases = /* @__PURE__ */ new Map();
|
|
1987
|
+
for (const event of events) {
|
|
1988
|
+
const previous = revisionBases.get(event.revisionId);
|
|
1989
|
+
if (previous && previous !== event.reviewOf)
|
|
1990
|
+
throw new Error(
|
|
1991
|
+
"Saved shared feedback binds one revision to conflicting design content."
|
|
1992
|
+
);
|
|
1993
|
+
revisionBases.set(event.revisionId, event.reviewOf);
|
|
1994
|
+
}
|
|
1995
|
+
const pageIssues = [...page.issues ?? []];
|
|
1996
|
+
for (const event of page.events ?? []) {
|
|
1997
|
+
if (eventBytes.has(event.id)) {
|
|
1998
|
+
if (eventBytes.get(event.id) !== canonicalizeJson(event))
|
|
1999
|
+
pageIssues.push({
|
|
2000
|
+
id: event.id,
|
|
2001
|
+
sequence: event.sequence,
|
|
2002
|
+
reason: "Shared feedback reuses an event identity with changed bytes."
|
|
2003
|
+
});
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
try {
|
|
2007
|
+
const boundReviewOf = revisionBases.get(event.revisionId);
|
|
2008
|
+
if (boundReviewOf && boundReviewOf !== event.reviewOf)
|
|
2009
|
+
throw new Error("A shared revision identity is bound to conflicting design content.");
|
|
2010
|
+
const candidate = mergeWorkspaceFeedback([...events, event], {
|
|
2011
|
+
revisionId: event.revisionId,
|
|
2012
|
+
reviewOf: event.reviewOf,
|
|
2013
|
+
ownerPublicKey: record.custody.ownerPublicKey
|
|
2014
|
+
});
|
|
2015
|
+
const invalid2 = candidate.issues?.find(
|
|
2016
|
+
(issue) => (issue.id ?? issue.eventId) === event.id
|
|
2017
|
+
);
|
|
2018
|
+
if (invalid2) throw new Error(invalid2.reason);
|
|
2019
|
+
events.push(event);
|
|
2020
|
+
eventBytes.set(event.id, canonicalizeJson(event));
|
|
2021
|
+
revisionBases.set(event.revisionId, event.reviewOf);
|
|
2022
|
+
imported++;
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
pageIssues.push({ id: event.id, sequence: event.sequence, reason: error.message });
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
const revisions = /* @__PURE__ */ new Map();
|
|
2028
|
+
for (const event of events) {
|
|
2029
|
+
const previous = revisions.get(event.revisionId);
|
|
2030
|
+
if (previous && previous !== event.reviewOf)
|
|
2031
|
+
throw new Error(
|
|
2032
|
+
"Saved shared feedback binds one revision to conflicting design content."
|
|
2033
|
+
);
|
|
2034
|
+
revisions.set(event.revisionId, event.reviewOf);
|
|
2035
|
+
}
|
|
2036
|
+
const importedReviews = { ...earlier.importedReviews ?? {} }, directions = [], metadataByRevision = {};
|
|
2037
|
+
await withArtifactReviewLock(reviewPath, () => {
|
|
2038
|
+
const ledger = readArtifactReviewState(reviewPath, { allowMissing: true }) ?? createReviewLedger({ artifactId: reviewKey, currentReviewOf: currentDigest });
|
|
2039
|
+
const entries = new Map(ledger.reviews.map((entry) => [entry.review.reviewId, entry]));
|
|
2040
|
+
for (const [revisionId, reviewOf] of revisions) {
|
|
2041
|
+
const merged = mergeWorkspaceFeedback(events, {
|
|
2042
|
+
revisionId,
|
|
2043
|
+
reviewOf,
|
|
2044
|
+
ownerPublicKey: record.custody.ownerPublicKey
|
|
2045
|
+
});
|
|
2046
|
+
metadataByRevision[revisionId] = merged.metadata;
|
|
2047
|
+
directions.push(...merged.directions);
|
|
2048
|
+
pageIssues.push(...merged.issues ?? []);
|
|
2049
|
+
const review = structuredClone(merged.review);
|
|
2050
|
+
const previous = entries.get(review.reviewId)?.review;
|
|
2051
|
+
const previousImport = earlier.importedReviews?.[review.reviewId];
|
|
2052
|
+
importedReviews[review.reviewId] = structuredClone(review);
|
|
2053
|
+
if (previous) {
|
|
2054
|
+
review.decision = previous.decision;
|
|
2055
|
+
if (previousImport && previous.overall !== previousImport.overall)
|
|
2056
|
+
review.overall = previous.overall;
|
|
2057
|
+
review.pins = review.pins.map((pin) => {
|
|
2058
|
+
const saved = previous.pins.find((item) => item.id === pin.id);
|
|
2059
|
+
const importedPin = previousImport?.pins.find((item) => item.id === pin.id);
|
|
2060
|
+
if (!saved) return pin;
|
|
2061
|
+
const replies = new Map(
|
|
2062
|
+
[...pin.replies, ...saved.replies].map((reply) => [reply.id, reply])
|
|
2063
|
+
);
|
|
2064
|
+
return {
|
|
2065
|
+
...pin,
|
|
2066
|
+
...importedPin && saved.status !== importedPin.status ? { status: saved.status, updatedAt: saved.updatedAt } : {},
|
|
2067
|
+
replies: [...replies.values()]
|
|
2068
|
+
};
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
entries.set(review.reviewId, { review, stale: reviewOf !== currentDigest });
|
|
2072
|
+
}
|
|
2073
|
+
writeArtifactReviewState(
|
|
2074
|
+
reviewPath,
|
|
2075
|
+
createReviewLedger({
|
|
2076
|
+
artifactId: reviewKey,
|
|
2077
|
+
currentReviewOf: currentDigest,
|
|
2078
|
+
reviews: [...entries.values()]
|
|
2079
|
+
})
|
|
2080
|
+
);
|
|
2081
|
+
});
|
|
2082
|
+
issues = [
|
|
2083
|
+
...new Map(
|
|
2084
|
+
[...earlier.issues ?? [], ...pageIssues].map((issue) => [
|
|
2085
|
+
`${issue.id ?? issue.eventId}:${issue.sequence}`,
|
|
2086
|
+
issue
|
|
2087
|
+
])
|
|
2088
|
+
).values()
|
|
2089
|
+
];
|
|
2090
|
+
atomicJson(ledgerPath, {
|
|
2091
|
+
schemaVersion: "1.0.0",
|
|
2092
|
+
workspaceId: record.custody.id,
|
|
2093
|
+
events,
|
|
2094
|
+
issues,
|
|
2095
|
+
directions,
|
|
2096
|
+
metadataByRevision,
|
|
2097
|
+
importedReviews
|
|
2098
|
+
});
|
|
2099
|
+
const next = page.cursor;
|
|
2100
|
+
hasMore = Boolean(page.hasMore) && next > record.lastEvent;
|
|
2101
|
+
record.lastEvent = next;
|
|
2102
|
+
save(record);
|
|
2103
|
+
}
|
|
2104
|
+
return { ok: true, shared: true, imported, issues, reviewPath, ...safeStatus(record, current) };
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
export {
|
|
2109
|
+
createReviewLedger,
|
|
2110
|
+
mergeReviewLedger,
|
|
2111
|
+
effectiveReviewDecision,
|
|
2112
|
+
ARTIFACT_REVIEW_MAX_STATE_BYTES,
|
|
2113
|
+
readArtifactReviewState,
|
|
2114
|
+
writeArtifactReviewState,
|
|
2115
|
+
withArtifactReviewLock,
|
|
2116
|
+
exportArtifactReview,
|
|
2117
|
+
resolveArtifactReviewDestination,
|
|
2118
|
+
prepareDesignShareBundle,
|
|
2119
|
+
getDesignShareStatus,
|
|
2120
|
+
shareDesign,
|
|
2121
|
+
publishDesignShare,
|
|
2122
|
+
manageDesignShare,
|
|
2123
|
+
publishDesignReviewMetadata,
|
|
2124
|
+
exportDesignShareRecovery,
|
|
2125
|
+
importDesignShareRecovery,
|
|
2126
|
+
syncDesignShare
|
|
2127
|
+
};
|