@legionworks/facet 1.9.0 → 1.10.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/README.md +50 -21
- package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
- package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
- package/dist/gallery/frame/frame.css +8 -1
- package/dist/gallery/frame/runtime/chart.js +23 -1
- package/dist/gallery/index.html +4 -2
- package/docs/reference/cli.md +29 -1
- package/docs/reference/export.md +4 -0
- package/docs/reference/mcp.md +16 -8
- package/docs/reference/security.md +7 -0
- package/docs/reference/storage.md +6 -1
- package/docs/reference/tsx.md +14 -0
- package/docs/reference/validation.md +34 -6
- package/package.json +4 -4
- package/skills/facet/SKILL.md +24 -1
- package/src/cli/commands/promote.ts +1 -0
- package/src/cli/commands/templates.ts +25 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/parser.ts +4 -0
- package/src/cli/presenter.ts +48 -0
- package/src/gallery-web/app.ts +45 -1
- package/src/gallery-web/favicon.ts +1 -0
- package/src/gallery-web/frame/renderers/chart.ts +31 -1
- package/src/gallery-web/frame/styles/frame.css +8 -1
- package/src/gallery-web/frame-html.ts +2 -1
- package/src/gallery-web/index.html +3 -1
- package/src/gallery-web/styles/verdict.css +19 -4
- package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
- package/src/harness-adapters/mcp/main.ts +2 -0
- package/src/harness-adapters/mcp/server.ts +92 -69
- package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
- package/src/service/dispatcher.ts +10 -0
- package/src/service/router-guards.ts +2 -0
- package/src/service/store/migrations.ts +12 -2
- package/src/service/store/repository-lifecycle.ts +91 -19
- package/src/service/store/repository.ts +60 -2
- package/src/service/store/schema.ts +4 -0
- package/src/service/stored-verdict.ts +3 -0
- package/src/shared/contracts/artifact.ts +1 -0
- package/src/shared/contracts/commands/index.ts +10 -0
- package/src/shared/contracts/commands/names.ts +2 -0
- package/src/shared/contracts/commands/requests.ts +7 -0
- package/src/shared/contracts/commands/results.ts +20 -1
- package/src/shared/contracts/promotion.ts +16 -0
- package/src/shared/contracts/validation.ts +7 -0
- package/src/shared/errors/facet-error.ts +2 -0
- package/src/shared/errors/store-error.ts +4 -0
- package/src/shared/html/artifact-main.ts +3 -0
- package/src/shared/storage-version.ts +1 -1
- package/src/validation/tier0/dom-shim.ts +3 -18
- package/src/validation/tier0/markdown.ts +28 -5
- package/src/validation/tier0/mermaid.ts +17 -8
- package/src/validation/tier0/worker-dispatch.ts +1 -1
- package/src/validation/tier1/entries/tsx.ts +1 -0
- package/src/validation/tier1/harness.ts +2 -1
- package/src/validation/tier1/isolated-probe.ts +4 -0
- package/src/validation/tier1/protocol-probe.ts +37 -0
- package/src/validation/tier1/runner.ts +21 -4
- package/src/validation/tier1/verdict.ts +24 -39
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { type ZodType, ZodError } from "zod";
|
|
5
8
|
import { FACET_VERSION } from "../../shared/version";
|
|
6
9
|
|
|
7
10
|
import { buildFacetArgs, FacetBridgeError, invokeFacet } from "./cli-bridge";
|
|
8
11
|
import {
|
|
12
|
+
CreateToolSchema,
|
|
9
13
|
ExportToolSchema,
|
|
10
14
|
OpenUrlToolSchema,
|
|
11
15
|
PublishToolSchema,
|
|
@@ -41,12 +45,10 @@ function errorEnvelope(cause: unknown): FacetEnvelope<never> {
|
|
|
41
45
|
return errEnvelope(requestId(), body);
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
const LooseToolInputSchema = z.record(z.unknown());
|
|
45
|
-
|
|
46
48
|
function toolResult(envelope: FacetEnvelope<unknown>) {
|
|
47
49
|
return {
|
|
48
50
|
content: [{ type: "text" as const, text: JSON.stringify(envelope) }],
|
|
49
|
-
|
|
51
|
+
isError: !envelope.ok,
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -90,75 +92,96 @@ function publishSource(input: PublishToolInput): {
|
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
withInput(args, ExportToolSchema.parse, async (input) => {
|
|
105
|
-
mkdirSync(input.outDir, { recursive: true });
|
|
106
|
-
return invoke(buildFacetArgs("export", input), { cwd: input.outDir });
|
|
107
|
-
}),
|
|
108
|
-
);
|
|
95
|
+
function defineTool<T>(
|
|
96
|
+
schema: ZodType<T>,
|
|
97
|
+
description: string,
|
|
98
|
+
run: (input: T) => Promise<ReturnType<typeof toolResult>>,
|
|
99
|
+
) {
|
|
100
|
+
return {
|
|
101
|
+
schema,
|
|
102
|
+
description,
|
|
103
|
+
execute: (args: unknown) => withInput(args, (input) => schema.parse(input), run),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
109
106
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
"Return a Facet gallery frameUrl without launching a browser. This always invokes facet open --no-launch; agents must not launch desktop display.",
|
|
115
|
-
inputSchema: LooseToolInputSchema,
|
|
116
|
-
},
|
|
117
|
-
async (args) =>
|
|
118
|
-
withInput(args, OpenUrlToolSchema.parse, async (input) =>
|
|
119
|
-
invoke(buildFacetArgs("open", input)),
|
|
120
|
-
),
|
|
107
|
+
export function createFacetMcpServer(): Server {
|
|
108
|
+
const server = new Server(
|
|
109
|
+
{ name: "facet", version: FACET_VERSION },
|
|
110
|
+
{ capabilities: { tools: {} } },
|
|
121
111
|
);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
112
|
+
// The high-level SDK validates arguments before the handler and emits plain-text errors.
|
|
113
|
+
// Wire schemas and handler parsing are separate so invalid calls retain Facet envelopes.
|
|
114
|
+
const tools = {
|
|
115
|
+
facet_create: defineTool(
|
|
116
|
+
CreateToolSchema,
|
|
117
|
+
"Create an artifact from projectId, slug, and title before publishing source.",
|
|
118
|
+
async (input) => invoke(buildFacetArgs("create", input)),
|
|
119
|
+
),
|
|
120
|
+
facet_export: defineTool(
|
|
121
|
+
ExportToolSchema,
|
|
122
|
+
"Export source or stored render evidence into outDir. Check envelope.ok for transport success; a successful publish verdict remains a separate data.verdict.status decision.",
|
|
123
|
+
async (input) => {
|
|
124
|
+
const outDir = resolve(input.outDir);
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(outDir, { recursive: true });
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
throw new FacetBridgeError(
|
|
129
|
+
{
|
|
130
|
+
code: "output_unwritable",
|
|
131
|
+
message: `Cannot write export output: ${outDir}`,
|
|
132
|
+
retryable: false,
|
|
133
|
+
details: { out: outDir },
|
|
134
|
+
},
|
|
135
|
+
cause,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return invoke(buildFacetArgs("export", input), { cwd: outDir });
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
facet_open_url: defineTool(
|
|
142
|
+
OpenUrlToolSchema,
|
|
143
|
+
"Return a Facet gallery frameUrl without launching a browser. This always invokes facet open --no-launch; agents must not launch desktop display.",
|
|
144
|
+
async (input) => invoke(buildFacetArgs("open", input)),
|
|
145
|
+
),
|
|
146
|
+
facet_publish: defineTool(
|
|
147
|
+
PublishToolSchema,
|
|
148
|
+
"Publish exactly one sourceText or local file. Check envelope.ok separately from data.verdict.status: stored verdict status error is not a transport failure.",
|
|
149
|
+
async (input) => {
|
|
132
150
|
const source = publishSource(input);
|
|
133
151
|
return invoke(buildFacetArgs("publish", { ...input, ...source }), source);
|
|
152
|
+
},
|
|
153
|
+
),
|
|
154
|
+
facet_read_back: defineTool(
|
|
155
|
+
ReadBackToolSchema,
|
|
156
|
+
"Read back the latest or revision-bound stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence; inspect envelope.ok before verdict status.",
|
|
157
|
+
async (input) => invoke(buildFacetArgs("read_back", input)),
|
|
158
|
+
),
|
|
159
|
+
facet_status: defineTool(
|
|
160
|
+
StatusToolSchema,
|
|
161
|
+
"Read Facet status. Set start only when activation is intended; envelope.ok reports command transport and never replaces verdict.status inspection.",
|
|
162
|
+
async (input) => invoke(buildFacetArgs("status", input)),
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
166
|
+
tools: Object.entries(tools).map(([name, tool]) => ({
|
|
167
|
+
name,
|
|
168
|
+
description: tool.description,
|
|
169
|
+
inputSchema: toJsonSchemaCompat(tool.schema, { pipeStrategy: "input" }),
|
|
170
|
+
})),
|
|
171
|
+
}));
|
|
172
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
173
|
+
const tool = Object.hasOwn(tools, params.name)
|
|
174
|
+
? tools[params.name as keyof typeof tools]
|
|
175
|
+
: null;
|
|
176
|
+
if (tool) return tool.execute(params.arguments);
|
|
177
|
+
return toolResult(
|
|
178
|
+
errEnvelope(requestId(), {
|
|
179
|
+
code: "invalid_request",
|
|
180
|
+
message: `Unknown MCP tool '${params.name}'`,
|
|
181
|
+
retryable: false,
|
|
134
182
|
}),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
server.registerTool(
|
|
138
|
-
"facet_read_back",
|
|
139
|
-
{
|
|
140
|
-
description:
|
|
141
|
-
"Read back the latest or revision-bound stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence; inspect envelope.ok before verdict status.",
|
|
142
|
-
inputSchema: LooseToolInputSchema,
|
|
143
|
-
},
|
|
144
|
-
async (args) =>
|
|
145
|
-
withInput(args, ReadBackToolSchema.parse, async (input) =>
|
|
146
|
-
invoke(buildFacetArgs("read_back", input)),
|
|
147
|
-
),
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
server.registerTool(
|
|
151
|
-
"facet_status",
|
|
152
|
-
{
|
|
153
|
-
description:
|
|
154
|
-
"Read Facet status. Set start only when activation is intended; envelope.ok reports command transport and never replaces verdict.status inspection.",
|
|
155
|
-
inputSchema: LooseToolInputSchema,
|
|
156
|
-
},
|
|
157
|
-
async (args) =>
|
|
158
|
-
withInput(args, StatusToolSchema.parse, async (input) =>
|
|
159
|
-
invoke(buildFacetArgs("status", input)),
|
|
160
|
-
),
|
|
161
|
-
);
|
|
183
|
+
);
|
|
184
|
+
});
|
|
162
185
|
|
|
163
186
|
return server;
|
|
164
187
|
}
|
|
@@ -6,6 +6,13 @@ const RevisionShaSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
|
6
6
|
const ExecutionSchema = z.enum(["static", "interactive"]);
|
|
7
7
|
const ReadBackTierSchema = z.union([z.literal(0), z.literal(1), z.literal("visual")]);
|
|
8
8
|
|
|
9
|
+
export const CreateToolShape = {
|
|
10
|
+
projectId: z.string().min(1),
|
|
11
|
+
slug: z.string().min(1),
|
|
12
|
+
title: z.string().min(1),
|
|
13
|
+
};
|
|
14
|
+
export const CreateToolSchema = z.object(CreateToolShape).strict();
|
|
15
|
+
|
|
9
16
|
export const PublishToolShape = {
|
|
10
17
|
artifactId: z.string().min(1),
|
|
11
18
|
type: ArtifactTypeSchema,
|
|
@@ -513,6 +513,13 @@ export async function dispatch(
|
|
|
513
513
|
nextCursor: null,
|
|
514
514
|
};
|
|
515
515
|
}
|
|
516
|
+
case "templates": {
|
|
517
|
+
return {
|
|
518
|
+
command: "templates",
|
|
519
|
+
requestId,
|
|
520
|
+
templates: deps.repository.listTemplates(command.limit),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
516
523
|
case "readBack": {
|
|
517
524
|
const revision =
|
|
518
525
|
command.revisionSha === undefined
|
|
@@ -642,6 +649,9 @@ export async function dispatch(
|
|
|
642
649
|
revisionId: command.revisionId,
|
|
643
650
|
name: command.name,
|
|
644
651
|
promotedBy: command.promotedBy,
|
|
652
|
+
...(command.allowUnverified !== undefined
|
|
653
|
+
? { allowUnverified: command.allowUnverified }
|
|
654
|
+
: {}),
|
|
645
655
|
...(command.description !== undefined ? { description: command.description } : {}),
|
|
646
656
|
});
|
|
647
657
|
return { command: "promote", requestId, template };
|
|
@@ -222,6 +222,8 @@ export function statusFor(error: FacetError): number {
|
|
|
222
222
|
case "tier0_unavailable":
|
|
223
223
|
return 503;
|
|
224
224
|
case "duplicate_revision":
|
|
225
|
+
case "template_name_taken":
|
|
226
|
+
case "promotion_refused":
|
|
225
227
|
case "constraint":
|
|
226
228
|
case "foreign_key":
|
|
227
229
|
case "immutable_revision":
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
V7_SCHEMA_FRAGMENT,
|
|
13
13
|
V8_SCHEMA_FRAGMENT,
|
|
14
14
|
V9_SCHEMA_FRAGMENT,
|
|
15
|
+
V10_SCHEMA_FRAGMENT,
|
|
15
16
|
} from "./schema";
|
|
16
|
-
import { CURRENT_STORAGE_VERSION } from "../../shared/storage-version";
|
|
17
17
|
|
|
18
18
|
export interface MigrationOptions {
|
|
19
19
|
readonly beforeRecordVersion?: (version: number) => void;
|
|
@@ -106,13 +106,23 @@ const MIGRATION_STEPS: readonly MigrationStep[] = [
|
|
|
106
106
|
requiresForeignKeyDisable: true,
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
|
-
version:
|
|
109
|
+
version: 9,
|
|
110
110
|
apply: (db) => {
|
|
111
111
|
db.exec(V9_SCHEMA_FRAGMENT);
|
|
112
112
|
},
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
version: 10,
|
|
116
|
+
apply: (db) => {
|
|
117
|
+
db.exec(V10_SCHEMA_FRAGMENT);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
114
120
|
];
|
|
115
121
|
|
|
122
|
+
export function latestMigrationVersion(): number {
|
|
123
|
+
return MIGRATION_STEPS.at(-1)?.version ?? 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
export function runMigrations(db: Database, options: MigrationOptions = {}): void {
|
|
117
127
|
try {
|
|
118
128
|
db.exec(
|
|
@@ -2,6 +2,11 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
|
|
3
3
|
import { type Template, TemplateSchema } from "../../shared/contracts/artifact";
|
|
4
4
|
import { now } from "../../shared/util/time";
|
|
5
|
+
import { FacetError } from "../../shared/errors/facet-error";
|
|
6
|
+
import { PROMOTION_GATE } from "../../shared/contracts/promotion";
|
|
7
|
+
import type { RenderStatus } from "../../shared/contracts/validation";
|
|
8
|
+
import { verdictFromStoredRun } from "../stored-verdict";
|
|
9
|
+
import type { ArtifactRepository } from "./repository";
|
|
5
10
|
import { asStoreError, FacetStoreError } from "./database";
|
|
6
11
|
|
|
7
12
|
export interface TemplateInput {
|
|
@@ -11,6 +16,7 @@ export interface TemplateInput {
|
|
|
11
16
|
readonly description?: string | null;
|
|
12
17
|
readonly promotedBy: string;
|
|
13
18
|
readonly promotedAt?: string;
|
|
19
|
+
readonly promotionOverride?: string | null;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export interface PromoteRevisionInput {
|
|
@@ -20,6 +26,7 @@ export interface PromoteRevisionInput {
|
|
|
20
26
|
readonly description?: string | null;
|
|
21
27
|
readonly promotedBy: string;
|
|
22
28
|
readonly promotedAt?: string;
|
|
29
|
+
readonly allowUnverified?: boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export function evictRevisions(
|
|
@@ -59,23 +66,75 @@ export function evictRevisions(
|
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
export function promoteRevision(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
69
|
+
export function promoteRevision(
|
|
70
|
+
db: Database,
|
|
71
|
+
repository: ArtifactRepository,
|
|
72
|
+
input: PromoteRevisionInput,
|
|
73
|
+
): Template {
|
|
74
|
+
return db
|
|
75
|
+
.transaction(() => {
|
|
76
|
+
const owner = db
|
|
77
|
+
.query("SELECT artifact_id FROM revisions WHERE id = ?")
|
|
78
|
+
.get(input.revisionId) as { artifact_id: string } | null;
|
|
79
|
+
if (!owner)
|
|
80
|
+
throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
|
|
81
|
+
// The template table has independent foreign keys on artifact_id and
|
|
82
|
+
// revision_id, not a composite ownership constraint, so an explicit
|
|
83
|
+
// artifactId that disagrees with the revision's real owner would
|
|
84
|
+
// otherwise insert silently — instantiation would then copy the wrong
|
|
85
|
+
// artifact's bytes under the caller-supplied artifact's identity.
|
|
86
|
+
if (input.artifactId !== undefined && input.artifactId !== owner.artifact_id) {
|
|
87
|
+
throw new FacetStoreError(
|
|
88
|
+
"foreign_key",
|
|
89
|
+
`Revision ${input.revisionId} belongs to artifact ${owner.artifact_id}, not ${input.artifactId}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const revision = repository.getRevisionById(input.revisionId);
|
|
93
|
+
if (revision === null)
|
|
94
|
+
throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
|
|
95
|
+
const tier1 = repository.listRenderRuns({ revisionId: revision.id, tier: 1 })[0];
|
|
96
|
+
const tier0 = repository.listRenderRuns({ revisionId: revision.id, tier: 0 })[0];
|
|
97
|
+
// Read the marker from the reconstructed verdict object rather than
|
|
98
|
+
// the raw `insecure_json` column — `verdictFromStoredRun` is the single
|
|
99
|
+
// source of truth for cross-boundary reconstruction and keeps the
|
|
100
|
+
// precedence between status, tier, and marker consistent with the
|
|
101
|
+
// dispatcher's wire form.
|
|
102
|
+
const tier1Verdict = tier1 === undefined ? null : verdictFromStoredRun(revision, tier1);
|
|
103
|
+
const tier0Verdict = tier0 === undefined ? null : verdictFromStoredRun(revision, tier0);
|
|
104
|
+
const tier1Status = tier1Verdict === null ? null : tier1Verdict.status;
|
|
105
|
+
const tier0Status = tier0Verdict === null ? null : tier0Verdict.status;
|
|
106
|
+
// Level 3 opts out of the validation contract (publish skips Tier 0). A
|
|
107
|
+
// visual read-back still runs the browser, but a result carrying
|
|
108
|
+
// `insecure.level === 3` is not trusted verification for a template, so
|
|
109
|
+
// refuse even when the status alone would allow. Levels 1 and 2 keep the
|
|
110
|
+
// status decision: they run real validators under relaxed sandboxing.
|
|
111
|
+
const insecureLevel3 = tier1Verdict !== null && tier1Verdict.insecure?.level === 3;
|
|
112
|
+
const reason =
|
|
113
|
+
tier1Status === null
|
|
114
|
+
? "no_visual_verification"
|
|
115
|
+
: tier0Status === "error"
|
|
116
|
+
? "error"
|
|
117
|
+
: insecureLevel3
|
|
118
|
+
? "insecure:unvalidated"
|
|
119
|
+
: PROMOTION_GATE[tier1Status as RenderStatus] === "allow"
|
|
120
|
+
? null
|
|
121
|
+
: tier1Status;
|
|
122
|
+
if (reason !== null && input.allowUnverified !== true) {
|
|
123
|
+
throw new FacetError(
|
|
124
|
+
"promotion_refused",
|
|
125
|
+
`Promotion refused (${reason}). Run facet read-back --artifact-id ${owner.artifact_id} --tier visual on revision ${input.revisionId}, or pass --allow-unverified to record an override.`,
|
|
126
|
+
{
|
|
127
|
+
details: { revisionId: input.revisionId, reason, tier1Status, tier0Status },
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return createTemplate(db, {
|
|
132
|
+
...input,
|
|
133
|
+
artifactId: owner.artifact_id,
|
|
134
|
+
promotionOverride: reason,
|
|
135
|
+
});
|
|
136
|
+
})
|
|
137
|
+
.immediate();
|
|
79
138
|
}
|
|
80
139
|
|
|
81
140
|
export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
@@ -88,10 +147,11 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
|
88
147
|
description: input.description ?? null,
|
|
89
148
|
promotedBy: input.promotedBy,
|
|
90
149
|
promotedAt,
|
|
150
|
+
promotionOverride: input.promotionOverride ?? null,
|
|
91
151
|
};
|
|
92
152
|
try {
|
|
93
153
|
db.query(
|
|
94
|
-
"INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
154
|
+
"INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
95
155
|
).run(
|
|
96
156
|
value.id,
|
|
97
157
|
value.artifactId,
|
|
@@ -100,10 +160,22 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
|
100
160
|
value.description,
|
|
101
161
|
value.promotedBy,
|
|
102
162
|
value.promotedAt,
|
|
163
|
+
value.promotionOverride,
|
|
103
164
|
);
|
|
104
165
|
return TemplateSchema.parse(value);
|
|
105
166
|
} catch (error) {
|
|
106
|
-
|
|
167
|
+
const mapped = asStoreError(error);
|
|
168
|
+
if (mapped.code === "template_name_taken") {
|
|
169
|
+
throw new FacetStoreError(
|
|
170
|
+
"template_name_taken",
|
|
171
|
+
`Template name already exists: ${input.name}`,
|
|
172
|
+
{
|
|
173
|
+
cause: error,
|
|
174
|
+
details: { name: input.name },
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
throw mapped;
|
|
107
179
|
}
|
|
108
180
|
}
|
|
109
181
|
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type TemplateInput,
|
|
33
33
|
} from "./repository-lifecycle";
|
|
34
34
|
import { enforceEvidenceRetention, removeUnreferencedEvidence } from "./evidence-retention";
|
|
35
|
+
import { latestStoredVerdict } from "../stored-verdict";
|
|
35
36
|
|
|
36
37
|
interface ProjectInput {
|
|
37
38
|
readonly projectRoot: string;
|
|
@@ -407,7 +408,7 @@ export class ArtifactRepository {
|
|
|
407
408
|
try {
|
|
408
409
|
const row = this.db
|
|
409
410
|
.query(
|
|
410
|
-
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
|
|
411
|
+
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
|
|
411
412
|
)
|
|
412
413
|
.get(name) as {
|
|
413
414
|
id: string;
|
|
@@ -417,6 +418,7 @@ export class ArtifactRepository {
|
|
|
417
418
|
description: string | null;
|
|
418
419
|
promoted_by: string;
|
|
419
420
|
promoted_at: string;
|
|
421
|
+
promotion_override: string | null;
|
|
420
422
|
} | null;
|
|
421
423
|
if (!row) return null;
|
|
422
424
|
return TemplateSchema.parse({
|
|
@@ -427,12 +429,68 @@ export class ArtifactRepository {
|
|
|
427
429
|
description: row.description,
|
|
428
430
|
promotedBy: row.promoted_by,
|
|
429
431
|
promotedAt: row.promoted_at,
|
|
432
|
+
promotionOverride: row.promotion_override,
|
|
430
433
|
});
|
|
431
434
|
} catch (error) {
|
|
432
435
|
throw asStoreError(error);
|
|
433
436
|
}
|
|
434
437
|
}
|
|
435
438
|
|
|
439
|
+
listTemplates(limit = DEFAULT_LIST_LIMIT): Array<{
|
|
440
|
+
name: string;
|
|
441
|
+
artifactId: string;
|
|
442
|
+
revisionId: string;
|
|
443
|
+
revisionSha: string;
|
|
444
|
+
promotedBy: string;
|
|
445
|
+
promotedAt: string;
|
|
446
|
+
promotionOverride: string | null;
|
|
447
|
+
sourceVerdict: {
|
|
448
|
+
status: import("../../shared/contracts/validation").RenderStatus;
|
|
449
|
+
tier: 0 | 1;
|
|
450
|
+
} | null;
|
|
451
|
+
}> {
|
|
452
|
+
const rows = this.db
|
|
453
|
+
.query(
|
|
454
|
+
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates ORDER BY promoted_at DESC, id DESC LIMIT ?",
|
|
455
|
+
)
|
|
456
|
+
.all(limit) as Array<{
|
|
457
|
+
id: string;
|
|
458
|
+
artifact_id: string;
|
|
459
|
+
revision_id: string;
|
|
460
|
+
name: string;
|
|
461
|
+
description: string | null;
|
|
462
|
+
promoted_by: string;
|
|
463
|
+
promoted_at: string;
|
|
464
|
+
promotion_override: string | null;
|
|
465
|
+
}>;
|
|
466
|
+
return rows.map((row) => {
|
|
467
|
+
const revision = this.getRevisionById(row.revision_id);
|
|
468
|
+
if (revision === null)
|
|
469
|
+
throw new FacetStoreError("foreign_key", `Template revision not found: ${row.revision_id}`);
|
|
470
|
+
const template = TemplateSchema.parse({
|
|
471
|
+
id: row.id,
|
|
472
|
+
artifactId: row.artifact_id,
|
|
473
|
+
revisionId: row.revision_id,
|
|
474
|
+
name: row.name,
|
|
475
|
+
description: row.description,
|
|
476
|
+
promotedBy: row.promoted_by,
|
|
477
|
+
promotedAt: row.promoted_at,
|
|
478
|
+
promotionOverride: row.promotion_override,
|
|
479
|
+
});
|
|
480
|
+
const verdict = latestStoredVerdict(this, revision);
|
|
481
|
+
return {
|
|
482
|
+
name: template.name,
|
|
483
|
+
artifactId: template.artifactId,
|
|
484
|
+
revisionId: template.revisionId,
|
|
485
|
+
revisionSha: revision.sha256,
|
|
486
|
+
promotedBy: template.promotedBy,
|
|
487
|
+
promotedAt: template.promotedAt,
|
|
488
|
+
promotionOverride: template.promotionOverride,
|
|
489
|
+
sourceVerdict: verdict === null ? null : { status: verdict.status, tier: verdict.tier },
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
436
494
|
createArtifact(input: ArtifactInput): Artifact {
|
|
437
495
|
const createdAt = now();
|
|
438
496
|
const value = { id: crypto.randomUUID(), ...input, createdAt, updatedAt: createdAt };
|
|
@@ -715,7 +773,7 @@ export class ArtifactRepository {
|
|
|
715
773
|
}
|
|
716
774
|
|
|
717
775
|
promoteRevision(input: PromoteRevisionInput): Template {
|
|
718
|
-
return promoteLifecycleRevision(this.db, input);
|
|
776
|
+
return promoteLifecycleRevision(this.db, this, input);
|
|
719
777
|
}
|
|
720
778
|
|
|
721
779
|
instantiateTemplate(input: TemplateInput): Template {
|
|
@@ -216,3 +216,7 @@ export const V9_SCHEMA_FRAGMENT = `
|
|
|
216
216
|
ALTER TABLE render_runs ADD COLUMN screenshot_format TEXT
|
|
217
217
|
CHECK(screenshot_format IN ('png','webp'));
|
|
218
218
|
`;
|
|
219
|
+
|
|
220
|
+
export const V10_SCHEMA_FRAGMENT = `
|
|
221
|
+
ALTER TABLE templates ADD COLUMN promotion_override TEXT;
|
|
222
|
+
`;
|
|
@@ -39,6 +39,9 @@ export function withTolerantObserved(parsed: unknown): VerdictObserved {
|
|
|
39
39
|
visibleSvgCount: num("visibleSvgCount", 0),
|
|
40
40
|
opaqueRegionCount: num("opaqueRegionCount", 0),
|
|
41
41
|
externalImageCount: num("externalImageCount", 0),
|
|
42
|
+
...(typeof observed.emptyRendererRoot === "boolean"
|
|
43
|
+
? { emptyRendererRoot: observed.emptyRendererRoot }
|
|
44
|
+
: {}),
|
|
42
45
|
// errorCount is required on the schema; pre-arc rows that never
|
|
43
46
|
// recorded it default to 0.
|
|
44
47
|
errorCount: num("errorCount", 0),
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
CreateRequestSchema,
|
|
34
34
|
InstantiateRequestSchema,
|
|
35
35
|
ListRequestSchema,
|
|
36
|
+
TemplatesRequestSchema,
|
|
36
37
|
OpenRequestSchema,
|
|
37
38
|
PinRequestSchema,
|
|
38
39
|
PromoteRequestSchema,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
type CreateRequest,
|
|
46
47
|
type InstantiateRequest,
|
|
47
48
|
type ListRequest,
|
|
49
|
+
type TemplatesRequest,
|
|
48
50
|
type OpenRequest,
|
|
49
51
|
type PinRequest,
|
|
50
52
|
type PromoteRequest,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
CreateResultSchema,
|
|
59
61
|
InstantiateResultSchema,
|
|
60
62
|
ListResultSchema,
|
|
63
|
+
TemplatesResultSchema,
|
|
61
64
|
OpenResultSchema,
|
|
62
65
|
PinResultSchema,
|
|
63
66
|
PromoteResultSchema,
|
|
@@ -72,6 +75,7 @@ import {
|
|
|
72
75
|
type CreateResult,
|
|
73
76
|
type InstantiateResult,
|
|
74
77
|
type ListResult,
|
|
78
|
+
type TemplatesResult,
|
|
75
79
|
type OpenResult,
|
|
76
80
|
type PinResult,
|
|
77
81
|
type PromoteResult,
|
|
@@ -98,6 +102,7 @@ export const CommandRequestSchema = z.discriminatedUnion("command", [
|
|
|
98
102
|
CreateRequestSchema,
|
|
99
103
|
PublishRequestSchema,
|
|
100
104
|
ListRequestSchema,
|
|
105
|
+
TemplatesRequestSchema,
|
|
101
106
|
ReadBackRequestSchema,
|
|
102
107
|
StatusRequestSchema,
|
|
103
108
|
OpenRequestSchema,
|
|
@@ -112,6 +117,7 @@ export const CommandResultSchema = z.discriminatedUnion("command", [
|
|
|
112
117
|
CreateResultSchema,
|
|
113
118
|
PublishResultSchema,
|
|
114
119
|
ListResultSchema,
|
|
120
|
+
TemplatesResultSchema,
|
|
115
121
|
ReadBackResultSchema,
|
|
116
122
|
StatusResultSchema,
|
|
117
123
|
OpenResultSchema,
|
|
@@ -145,6 +151,7 @@ export {
|
|
|
145
151
|
PublishRequestSchema,
|
|
146
152
|
PublishArtifactTypeSchema,
|
|
147
153
|
ListRequestSchema,
|
|
154
|
+
TemplatesRequestSchema,
|
|
148
155
|
ReadBackRequestSchema,
|
|
149
156
|
StatusRequestSchema,
|
|
150
157
|
OpenRequestSchema,
|
|
@@ -157,6 +164,7 @@ export {
|
|
|
157
164
|
CreateResultSchema,
|
|
158
165
|
PublishResultSchema,
|
|
159
166
|
ListResultSchema,
|
|
167
|
+
TemplatesResultSchema,
|
|
160
168
|
ReadBackResultSchema,
|
|
161
169
|
StatusResultSchema,
|
|
162
170
|
OpenResultSchema,
|
|
@@ -180,6 +188,7 @@ export type {
|
|
|
180
188
|
CreateRequest,
|
|
181
189
|
PublishRequest,
|
|
182
190
|
ListRequest,
|
|
191
|
+
TemplatesRequest,
|
|
183
192
|
ReadBackRequest,
|
|
184
193
|
StatusRequest,
|
|
185
194
|
OpenRequest,
|
|
@@ -191,6 +200,7 @@ export type {
|
|
|
191
200
|
CreateResult,
|
|
192
201
|
PublishResult,
|
|
193
202
|
ListResult,
|
|
203
|
+
TemplatesResult,
|
|
194
204
|
ReadBackResult,
|
|
195
205
|
StatusResult,
|
|
196
206
|
DoctorResult,
|
|
@@ -9,6 +9,7 @@ export const CommandNameSchema = z.enum([
|
|
|
9
9
|
"create",
|
|
10
10
|
"publish",
|
|
11
11
|
"list",
|
|
12
|
+
"templates",
|
|
12
13
|
"readBack",
|
|
13
14
|
"status",
|
|
14
15
|
"open",
|
|
@@ -23,6 +24,7 @@ export const IMPLEMENTED_COMMANDS: readonly CommandName[] = [
|
|
|
23
24
|
"create",
|
|
24
25
|
"publish",
|
|
25
26
|
"list",
|
|
27
|
+
"templates",
|
|
26
28
|
"readBack",
|
|
27
29
|
"status",
|
|
28
30
|
"open",
|
|
@@ -82,6 +82,12 @@ export const ListRequestSchema = BaseRequestSchema.extend({
|
|
|
82
82
|
});
|
|
83
83
|
export type ListRequest = z.infer<typeof ListRequestSchema>;
|
|
84
84
|
|
|
85
|
+
export const TemplatesRequestSchema = BaseRequestSchema.extend({
|
|
86
|
+
command: z.literal("templates"),
|
|
87
|
+
limit: z.number().int().positive().max(MAX_LIST_LIMIT).optional(),
|
|
88
|
+
});
|
|
89
|
+
export type TemplatesRequest = z.infer<typeof TemplatesRequestSchema>;
|
|
90
|
+
|
|
85
91
|
export const ReadBackRequestSchema = BaseRequestSchema.extend({
|
|
86
92
|
command: z.literal("readBack"),
|
|
87
93
|
artifactId: z.string().min(1),
|
|
@@ -116,6 +122,7 @@ export const PromoteRequestSchema = BaseRequestSchema.extend({
|
|
|
116
122
|
name: z.string().min(1),
|
|
117
123
|
description: z.string().nullable().optional(),
|
|
118
124
|
promotedBy: z.string().min(1),
|
|
125
|
+
allowUnverified: z.boolean().optional(),
|
|
119
126
|
});
|
|
120
127
|
export type PromoteRequest = z.infer<typeof PromoteRequestSchema>;
|
|
121
128
|
|