@caelo-cms/shared 0.11.1 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tools.d.ts +2 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +7 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +5 -0
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +1 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +28 -3
- package/dist/content.js.map +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lists/fields.d.ts +2 -0
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +3 -2
- package/dist/lists/fields.js.map +1 -1
- package/dist/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.js.map +1 -1
- package/dist/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- package/dist/preview-compose.js.map +1 -1
- package/dist/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +10 -0
- package/src/bug-intake.test.ts +47 -0
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +5 -0
- package/src/content.ts +37 -3
- package/src/css-var-scan.ts +3 -3
- package/src/index.ts +1 -0
- package/src/lists/fields.ts +3 -2
- package/src/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- package/src/version.ts +1 -1
package/dist/version.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
|
|
20
20
|
* follow standard SemVer.
|
|
21
21
|
*/
|
|
22
|
-
export declare const CAELO_VERSION = "0.11.
|
|
22
|
+
export declare const CAELO_VERSION = "0.11.3";
|
|
23
23
|
/**
|
|
24
24
|
* Deprecated alias for back-compat — early P17 work spelled this
|
|
25
25
|
* `CALEO_VERSION` (typo of "Caelo"). New code should import
|
|
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.11.1";
|
|
|
31
31
|
*
|
|
32
32
|
* @deprecated use CAELO_VERSION
|
|
33
33
|
*/
|
|
34
|
-
export declare const CALEO_VERSION = "0.11.
|
|
34
|
+
export declare const CALEO_VERSION = "0.11.3";
|
|
35
35
|
/**
|
|
36
36
|
* Parsed shape — major/minor/patch + optional pre-release tag.
|
|
37
37
|
* Stable interface for callers that need to feature-gate (rare —
|
package/dist/version.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
|
|
21
21
|
* follow standard SemVer.
|
|
22
22
|
*/
|
|
23
|
-
export const CAELO_VERSION = "0.11.
|
|
23
|
+
export const CAELO_VERSION = "0.11.3";
|
|
24
24
|
/**
|
|
25
25
|
* Deprecated alias for back-compat — early P17 work spelled this
|
|
26
26
|
* `CALEO_VERSION` (typo of "Caelo"). New code should import
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caelo-cms/shared",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
|
package/src/ai-tools.ts
CHANGED
|
@@ -725,6 +725,16 @@ export const moveModuleToolInput = z
|
|
|
725
725
|
.object({
|
|
726
726
|
pageId: z.string().uuid(),
|
|
727
727
|
moduleId: z.string().uuid(),
|
|
728
|
+
/**
|
|
729
|
+
* #1195 — which placement of `moduleId` to move when the page holds it
|
|
730
|
+
* more than once: its block and 0-based position there. Needed only
|
|
731
|
+
* then; the error names the placements.
|
|
732
|
+
*/
|
|
733
|
+
fromBlockName: z.string().min(1).max(80).optional(),
|
|
734
|
+
fromPosition: z.preprocess(
|
|
735
|
+
(v) => (typeof v === "string" && /^\d+$/.test(v) ? Number.parseInt(v, 10) : v),
|
|
736
|
+
z.number().int().min(0).max(1000).optional(),
|
|
737
|
+
),
|
|
728
738
|
toBlockName: z.string().min(1).max(80).optional(),
|
|
729
739
|
position: movePositionInputSchema,
|
|
730
740
|
})
|
package/src/bug-intake.test.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
11
|
import {
|
|
12
|
+
BUG_REPORT_KIND_BEFORE_1192,
|
|
12
13
|
bugIntakeReport,
|
|
13
14
|
bugIntakeSigningString,
|
|
14
15
|
computeBugFingerprint,
|
|
@@ -152,3 +153,49 @@ describe("bugIntakeReport", () => {
|
|
|
152
153
|
expect(parsed.success).toBe(false);
|
|
153
154
|
});
|
|
154
155
|
});
|
|
156
|
+
|
|
157
|
+
describe("bugIntakeReport kind (#1192)", () => {
|
|
158
|
+
const base = {
|
|
159
|
+
protocolVersion: 1,
|
|
160
|
+
reportId: "7f3c2b9e-1d4a-4e8b-9c0f-2a1b3c4d5e6f",
|
|
161
|
+
caeloVersion: "0.10.55",
|
|
162
|
+
fingerprint: "a".repeat(64),
|
|
163
|
+
source: "ai",
|
|
164
|
+
severity: "degraded",
|
|
165
|
+
blockedTask: false,
|
|
166
|
+
suspectedTool: null,
|
|
167
|
+
title: "t",
|
|
168
|
+
whatHappened: "w",
|
|
169
|
+
expected: "e",
|
|
170
|
+
evidence: null,
|
|
171
|
+
} as const;
|
|
172
|
+
|
|
173
|
+
it("parses a report without kind, as an install from before #1192 sends it, as a defect", () => {
|
|
174
|
+
expect(BUG_REPORT_KIND_BEFORE_1192).toBe("defect");
|
|
175
|
+
const parsed = bugIntakeReport.parse(base);
|
|
176
|
+
expect(parsed.kind).toBe("defect");
|
|
177
|
+
expect(parsed.severity).toBe("degraded");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("round-trips a suggestion, which carries no severity", () => {
|
|
181
|
+
const sent = { ...base, kind: "suggestion", severity: null };
|
|
182
|
+
const parsed = bugIntakeReport.parse(sent);
|
|
183
|
+
expect(parsed).toEqual(sent);
|
|
184
|
+
expect(bugIntakeReport.parse(JSON.parse(JSON.stringify(parsed)))).toEqual(sent);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("refuses a defect without severity, a graded suggestion and an auto-captured suggestion", () => {
|
|
188
|
+
const issues = (r: unknown) =>
|
|
189
|
+
bugIntakeReport.safeParse(r).error?.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
190
|
+
expect(issues({ ...base, kind: "defect", severity: null })).toEqual([
|
|
191
|
+
"severity: a defect needs a severity",
|
|
192
|
+
]);
|
|
193
|
+
expect(issues({ ...base, kind: "suggestion" })).toEqual([
|
|
194
|
+
"severity: a suggestion has no severity; send null",
|
|
195
|
+
]);
|
|
196
|
+
expect(issues({ ...base, source: "auto", kind: "suggestion", severity: null })).toEqual([
|
|
197
|
+
"kind: an auto-captured report is always a defect",
|
|
198
|
+
]);
|
|
199
|
+
expect(issues({ ...base, kind: "feature" })?.[0]).toStartWith("kind:");
|
|
200
|
+
});
|
|
201
|
+
});
|
package/src/bug-intake.ts
CHANGED
|
@@ -60,7 +60,41 @@ export const bugIntakeRegisterRequest = z
|
|
|
60
60
|
.strict();
|
|
61
61
|
export type BugIntakeRegisterRequest = z.infer<typeof bugIntakeRegisterRequest>;
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* What a report is about (#1192).
|
|
65
|
+
* defect — something in Caelo misbehaved: a tool contradicted its
|
|
66
|
+
* description, a render contradicted stored state, a call
|
|
67
|
+
* failed. Auto captures and provider errors are always this.
|
|
68
|
+
* suggestion — nothing misbehaved, but Caelo lacks something the AI needed:
|
|
69
|
+
* a capability, a tool, a docs page. Filed by the AI only.
|
|
70
|
+
* The intake keeps the two apart end to end: own title prefix and label, own
|
|
71
|
+
* daily issue cap, and matching never puts one kind into the other's cluster.
|
|
72
|
+
*/
|
|
73
|
+
export const bugReportKind = z.enum(["defect", "suggestion"]);
|
|
74
|
+
export type BugReportKind = z.infer<typeof bugReportKind>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The kind of a report that names none. Contract rule (#1192): installs
|
|
78
|
+
* released before `kind` existed send no kind, and everything they could
|
|
79
|
+
* file was a defect, so a missing kind IS `defect`. This is a protocol rule,
|
|
80
|
+
* not a guess: it holds for every protocol-1 report, and the intake applies
|
|
81
|
+
* the same rule to the records it stored before #1192.
|
|
82
|
+
*/
|
|
83
|
+
export const BUG_REPORT_KIND_BEFORE_1192: BugReportKind = "defect";
|
|
84
|
+
|
|
85
|
+
/** How badly a defect hurt the task. Defects only: a suggestion has no severity. */
|
|
86
|
+
export const bugReportSeverity = z.enum(["blocking", "degraded", "cosmetic"]);
|
|
87
|
+
export type BugReportSeverity = z.infer<typeof bugReportSeverity>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `POST /v1/reports` — one redacted bug report (signed).
|
|
91
|
+
*
|
|
92
|
+
* `kind` is optional on the wire only for the contract rule above; parsing
|
|
93
|
+
* always yields one. `severity` is set for a defect and `null` for a
|
|
94
|
+
* suggestion (nothing broke, so there is nothing to grade), and an
|
|
95
|
+
* auto-captured report is always a defect. A report that breaks either is
|
|
96
|
+
* refused, not repaired: it came from a broken client.
|
|
97
|
+
*/
|
|
64
98
|
export const bugIntakeReport = z
|
|
65
99
|
.object({
|
|
66
100
|
protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
|
|
@@ -69,7 +103,8 @@ export const bugIntakeReport = z
|
|
|
69
103
|
caeloVersion: z.string().min(1).max(64),
|
|
70
104
|
fingerprint: fingerprintSchema,
|
|
71
105
|
source: z.enum(["ai", "auto"]),
|
|
72
|
-
|
|
106
|
+
kind: bugReportKind.default(BUG_REPORT_KIND_BEFORE_1192),
|
|
107
|
+
severity: bugReportSeverity.nullable(),
|
|
73
108
|
blockedTask: z.boolean(),
|
|
74
109
|
suspectedTool: z.string().max(200).nullable(),
|
|
75
110
|
title: z.string().min(1).max(BUG_INTAKE_LIMITS.title),
|
|
@@ -77,7 +112,27 @@ export const bugIntakeReport = z
|
|
|
77
112
|
expected: z.string().min(1).max(BUG_INTAKE_LIMITS.expected),
|
|
78
113
|
evidence: z.string().max(BUG_INTAKE_LIMITS.evidence).nullable(),
|
|
79
114
|
})
|
|
80
|
-
.strict()
|
|
115
|
+
.strict()
|
|
116
|
+
.superRefine((r, ctx) => {
|
|
117
|
+
if (r.kind === "defect" && r.severity === null) {
|
|
118
|
+
ctx.addIssue({ code: "custom", path: ["severity"], message: "a defect needs a severity" });
|
|
119
|
+
}
|
|
120
|
+
if (r.kind === "suggestion" && r.severity !== null) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
path: ["severity"],
|
|
124
|
+
message: "a suggestion has no severity; send null",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (r.kind === "suggestion" && r.source === "auto") {
|
|
128
|
+
ctx.addIssue({
|
|
129
|
+
code: "custom",
|
|
130
|
+
path: ["kind"],
|
|
131
|
+
message: "an auto-captured report is always a defect",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
/** A parsed report: `kind` is always present. */
|
|
81
136
|
export type BugIntakeReport = z.infer<typeof bugIntakeReport>;
|
|
82
137
|
|
|
83
138
|
/**
|
package/src/build-page.ts
CHANGED
|
@@ -283,6 +283,11 @@ export const buildPageTargetSchema = z
|
|
|
283
283
|
code: z.ZodIssueCode.custom,
|
|
284
284
|
message:
|
|
285
285
|
`pageId targets an EXISTING page — drop ${createKeys.join(", ")}. ` +
|
|
286
|
+
// #1152 — status is a create-time field; publishing an existing
|
|
287
|
+
// page has its own tool.
|
|
288
|
+
(createKeys.includes("status")
|
|
289
|
+
? "`status` only applies to a page this call creates; publish an existing page with set_pages_status_many. "
|
|
290
|
+
: "") +
|
|
286
291
|
"To create a new page instead, omit pageId and pass slug + title.",
|
|
287
292
|
});
|
|
288
293
|
}
|
package/src/content.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
|
|
15
|
+
import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
|
|
15
16
|
import { listItemInputSchema } from "./lists/items.js";
|
|
16
17
|
import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
|
|
17
18
|
import { LIST_REF_RE } from "./lists/refs.js";
|
|
@@ -330,13 +331,31 @@ const moduleFieldModuleListSchema = z
|
|
|
330
331
|
* written, and the renderer reports `list-type-missing:<slug>` for a type
|
|
331
332
|
* that is gone.
|
|
332
333
|
*/
|
|
334
|
+
/** The shipped list types, quoted, for the missing-listType message. */
|
|
335
|
+
const SHIPPED_LIST_TYPE_SLUGS = [...DEFAULT_LIST_TYPES.map((t) => t.slug), SITE_TEXTS_LIST_TYPE]
|
|
336
|
+
.map((slug) => `"${slug}"`)
|
|
337
|
+
.join(", ");
|
|
338
|
+
|
|
333
339
|
const moduleFieldListSchema = z
|
|
334
340
|
.object({
|
|
335
341
|
name: moduleFieldName,
|
|
336
342
|
kind: z.literal("list"),
|
|
337
343
|
label: moduleFieldLabel,
|
|
338
|
-
/**
|
|
339
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Slug of the list type that defines the entries. REQUIRED — a missing
|
|
346
|
+
* one says which types exist (#1190), not just "expected string".
|
|
347
|
+
*/
|
|
348
|
+
listType: z
|
|
349
|
+
.string({
|
|
350
|
+
error: (iss) =>
|
|
351
|
+
iss.input === undefined
|
|
352
|
+
? `a list field needs listType: ${SHIPPED_LIST_TYPE_SLUGS} or a custom type's slug (list_list_types shows them)`
|
|
353
|
+
: undefined,
|
|
354
|
+
})
|
|
355
|
+
.regex(
|
|
356
|
+
/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
|
|
357
|
+
"slug must be lowercase letters, digits, or hyphens (1–64 chars, no leading/trailing hyphen)",
|
|
358
|
+
),
|
|
340
359
|
bind: z.enum(["local", "shared", "source"]).optional(),
|
|
341
360
|
/** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
|
|
342
361
|
list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
|
|
@@ -777,8 +796,23 @@ export const pageSetModulesSchema = z
|
|
|
777
796
|
.object({
|
|
778
797
|
blockName: slugSchema,
|
|
779
798
|
moduleIds: z.array(z.string().uuid()),
|
|
799
|
+
/**
|
|
800
|
+
* #1195 — the placement each entry of `moduleIds` IS, by its
|
|
801
|
+
* content instance (same index; null = a new placement). Given,
|
|
802
|
+
* a placement keeps its content wherever it moves — across
|
|
803
|
+
* blocks too — and two placements of one module never trade
|
|
804
|
+
* content. Omitted, bindings carry over per (block, module) in
|
|
805
|
+
* order, which cannot tell repeated placements apart.
|
|
806
|
+
*/
|
|
807
|
+
contentInstanceIds: z.array(z.string().uuid().nullable()).optional(),
|
|
780
808
|
})
|
|
781
|
-
.strict()
|
|
809
|
+
.strict()
|
|
810
|
+
.refine(
|
|
811
|
+
(b) =>
|
|
812
|
+
b.contentInstanceIds === undefined ||
|
|
813
|
+
b.contentInstanceIds.length === b.moduleIds.length,
|
|
814
|
+
{ message: "contentInstanceIds needs one entry (or null) per moduleIds entry" },
|
|
815
|
+
),
|
|
782
816
|
),
|
|
783
817
|
})
|
|
784
818
|
.strict();
|
package/src/css-var-scan.ts
CHANGED
|
@@ -143,11 +143,11 @@ export function formatUnknownCssVarWarning(unknown: readonly UnknownCssVar[]): s
|
|
|
143
143
|
const hint = u.suggestion !== null ? ` (did you mean \`${u.suggestion}\`?)` : "";
|
|
144
144
|
const fallback = u.hasFallback
|
|
145
145
|
? " — its hardcoded fallback is what visitors will see, detached from the theme"
|
|
146
|
-
: " —
|
|
146
|
+
: " — nothing defines it, so the property won't apply";
|
|
147
147
|
return `\`${u.name}\`${hint}${fallback}`;
|
|
148
148
|
});
|
|
149
149
|
return (
|
|
150
|
-
`⚠️ Unknown CSS vars (the active theme
|
|
151
|
-
"Use the exact names
|
|
150
|
+
`⚠️ Unknown CSS vars (neither the active theme nor any layout or template CSS defines them): ${parts.join("; ")}. ` +
|
|
151
|
+
"Use the exact token names (`get_theme`), or define the custom property in this CSS if it's intentional."
|
|
152
152
|
);
|
|
153
153
|
}
|
package/src/index.ts
CHANGED
package/src/lists/fields.ts
CHANGED
|
@@ -53,11 +53,12 @@ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
|
|
|
53
53
|
"site_text",
|
|
54
54
|
"recurse",
|
|
55
55
|
]);
|
|
56
|
-
|
|
56
|
+
/** Name prefixes reserved as a family (see {@link LIST_RESERVED_NAMES}). */
|
|
57
|
+
export const LIST_RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;
|
|
57
58
|
|
|
58
59
|
/** True when `name` is taken by the engine (see {@link LIST_RESERVED_NAMES}). */
|
|
59
60
|
export function isReservedListName(name: string): boolean {
|
|
60
|
-
return LIST_RESERVED_NAMES.has(name) ||
|
|
61
|
+
return LIST_RESERVED_NAMES.has(name) || LIST_RESERVED_PREFIXES.some((p) => name.startsWith(p));
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export const listNameSchema = z
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #1215 — the editor's live-refresh vocabulary. CLAUDE.md §11.B:
|
|
5
|
+
* clients must not poll the admin on a timer, because every request keeps an
|
|
6
|
+
* instance up. Instead the admin tells an open editor WHAT changed, as a
|
|
7
|
+
* topic, and the editor re-reads that one surface through its usual read
|
|
8
|
+
* endpoint (`/content/chat/<id>/pending`, `/api/agents`, …).
|
|
9
|
+
*
|
|
10
|
+
* A topic is a hint, never data: it carries no ids, no rows and no actor, so
|
|
11
|
+
* it is safe to hand to every signed-in tab. The read that follows goes
|
|
12
|
+
* through the endpoint's own permission checks.
|
|
13
|
+
*
|
|
14
|
+
* Topics come from the names of operations that committed (the Query API's
|
|
15
|
+
* `observeCommittedOperations`). Only WRITE operations are listed: a read
|
|
16
|
+
* classified as a change would make every refresh announce another change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One surface an editor tab may need to re-read. */
|
|
20
|
+
export const LIVE_TOPICS = [
|
|
21
|
+
/** Running agents (turns, subagents, external MCP agents). */
|
|
22
|
+
"agents",
|
|
23
|
+
/** Pending approvals: queued proposals and paused in-chat approval cards. */
|
|
24
|
+
"pending",
|
|
25
|
+
/** One-time credentials an approval left for the operator (#971). */
|
|
26
|
+
"credentials",
|
|
27
|
+
/** Quality-audit status and the Publish gate (#553). */
|
|
28
|
+
"quality",
|
|
29
|
+
/** Site-import crawl progress (#228). */
|
|
30
|
+
"crawl",
|
|
31
|
+
/** Deploy runs (Stage, Publish live, rollback). */
|
|
32
|
+
"deploys",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type LiveTopic = (typeof LIVE_TOPICS)[number];
|
|
36
|
+
|
|
37
|
+
const TOPIC_SET: ReadonlySet<string> = new Set(LIVE_TOPICS);
|
|
38
|
+
|
|
39
|
+
/** Is `value` a known topic? */
|
|
40
|
+
export function isLiveTopic(value: string): value is LiveTopic {
|
|
41
|
+
return TOPIC_SET.has(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a comma-separated topic list (a query parameter). Unknown names are
|
|
46
|
+
* an error, not dropped: a client asking for a topic the server does not
|
|
47
|
+
* know would otherwise wait for an event that can never come.
|
|
48
|
+
*/
|
|
49
|
+
export function parseLiveTopics(
|
|
50
|
+
raw: string | null,
|
|
51
|
+
): { ok: true; topics: LiveTopic[] } | { ok: false; unknown: string[] } {
|
|
52
|
+
const names = (raw ?? "")
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter((s) => s.length > 0);
|
|
56
|
+
const unknown = names.filter((n) => !isLiveTopic(n));
|
|
57
|
+
if (unknown.length > 0) return { ok: false, unknown };
|
|
58
|
+
return { ok: true, topics: [...new Set(names)] as LiveTopic[] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Write ops per topic. Heartbeats (`chat.heartbeat_agent_run`,
|
|
62
|
+
// `chat.renew_turn_lease`) and the audit worker's idle `claim_next` are left
|
|
63
|
+
// out on purpose: they commit on a clock, and a hint on a clock is the very
|
|
64
|
+
// polling this replaces.
|
|
65
|
+
const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
|
|
66
|
+
"chat.register_agent_run": ["agents"],
|
|
67
|
+
"chat.finish_agent_run": ["agents"],
|
|
68
|
+
"chat.cancel_agents": ["agents"],
|
|
69
|
+
"chat.allow_external_agent": ["agents"],
|
|
70
|
+
// An external agent's tool call: first sight of it lists it as running,
|
|
71
|
+
// and it may have queued a card.
|
|
72
|
+
"chat.touch_external_agent": ["agents", "pending"],
|
|
73
|
+
"chat.record_turn_approvals": ["pending"],
|
|
74
|
+
"chat.decide_turn_approval": ["pending"],
|
|
75
|
+
"chat.supersede_turn_approvals": ["pending"],
|
|
76
|
+
"chat.claim_turn_approvals": ["pending"],
|
|
77
|
+
// Proposal flows whose op names do not follow propose_* / *_proposal.
|
|
78
|
+
"skills.review_proposal": ["pending"],
|
|
79
|
+
"gateway.execute_rate_limit_proposal": ["pending"],
|
|
80
|
+
"gateway.reject_rate_limit_proposal": ["pending"],
|
|
81
|
+
"plugins.approve_installation": ["pending"],
|
|
82
|
+
"credentials.issue": ["credentials"],
|
|
83
|
+
"chat.stage": ["quality", "deploys"],
|
|
84
|
+
"chat.unstage": ["quality"],
|
|
85
|
+
"chat.finalize_stage": ["quality", "deploys"],
|
|
86
|
+
"chat.finalize_draft_stage": ["quality", "deploys"],
|
|
87
|
+
"chat.publish": ["quality", "deploys"],
|
|
88
|
+
"quality_audits.enqueue": ["quality"],
|
|
89
|
+
"quality_audits.record_result": ["quality"],
|
|
90
|
+
"quality_audits.retry": ["quality"],
|
|
91
|
+
"quality_audits.claim_chat_notification": ["quality"],
|
|
92
|
+
"quality_audits.ack_chat_notification": ["quality"],
|
|
93
|
+
"quality_audits.release_chat_notification": ["quality"],
|
|
94
|
+
"quality_audits.publish_anyway": ["quality"],
|
|
95
|
+
"imports.create_run": ["crawl"],
|
|
96
|
+
"imports.update_run_status": ["crawl"],
|
|
97
|
+
"imports.write_extracted_pages": ["crawl"],
|
|
98
|
+
"deploy.trigger": ["deploys"],
|
|
99
|
+
// #1211 — the Stage's steps: its run appears, ends failed, ends recorded.
|
|
100
|
+
"deploy.start_trigger": ["deploys"],
|
|
101
|
+
"deploy.fail_run": ["deploys"],
|
|
102
|
+
"deploy.finish_trigger": ["deploys"],
|
|
103
|
+
"deploy.begin_run": ["deploys"],
|
|
104
|
+
"deploy.update_progress": ["deploys"],
|
|
105
|
+
"deploy.promote": ["deploys"],
|
|
106
|
+
"deploy.rollback": ["deploys"],
|
|
107
|
+
"deploy.build_plugin_data": ["deploys"],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The topics a committed operation touches (empty for every other op).
|
|
112
|
+
*
|
|
113
|
+
* Every proposal op (`<domain>.propose_*`, `.execute_proposal`,
|
|
114
|
+
* `.reject_proposal`, `.cancel_proposal`) moves the pending strip; an
|
|
115
|
+
* applied proposal may also leave a one-time credential (#971) and, for
|
|
116
|
+
* the quality and deploy domains, move their state.
|
|
117
|
+
*
|
|
118
|
+
* @example liveTopicsOfOperation("users.execute_proposal") // ["pending", "credentials"]
|
|
119
|
+
*/
|
|
120
|
+
export function liveTopicsOfOperation(operationName: string): readonly LiveTopic[] {
|
|
121
|
+
const exact = EXACT[operationName];
|
|
122
|
+
if (exact) return exact;
|
|
123
|
+
const dot = operationName.lastIndexOf(".");
|
|
124
|
+
const domain = operationName.slice(0, dot);
|
|
125
|
+
const action = operationName.slice(dot + 1);
|
|
126
|
+
const extra: LiveTopic[] =
|
|
127
|
+
domain === "quality_audits" ? ["quality"] : domain === "deploy" ? ["deploys"] : [];
|
|
128
|
+
if (action === "execute_proposal") return ["pending", "credentials", ...extra];
|
|
129
|
+
if (
|
|
130
|
+
action === "propose" ||
|
|
131
|
+
action.startsWith("propose_") ||
|
|
132
|
+
action === "reject_proposal" ||
|
|
133
|
+
action === "cancel_proposal"
|
|
134
|
+
) {
|
|
135
|
+
return ["pending"];
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
@@ -158,6 +158,53 @@ describe("templates and free regions", () => {
|
|
|
158
158
|
expect(freeRegions(root, parseMarkup(stripMustache(template)))).toBeNull();
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("a section inside a tag is optional: dropped by default, kept on request (#1104)", () => {
|
|
162
|
+
const t =
|
|
163
|
+
'<div class="wrapc split {{#reverse}}rev{{/reverse}}" data-x="{{^a}}1{{/a}}"><p>{{t}}</p></div>';
|
|
164
|
+
expect(stripMustache(t)).toBe('<div class="wrapc split " data-x=""><p></p></div>');
|
|
165
|
+
expect(stripMustache(t, { keepOptionalAttributeText: true })).toBe(
|
|
166
|
+
'<div class="wrapc split rev" data-x="1"><p></p></div>',
|
|
167
|
+
);
|
|
168
|
+
// A section between elements still renders its body once (a list).
|
|
169
|
+
expect(stripMustache('<ul>{{#items}}<li class="i">{{.}}</li>{{/items}}</ul>')).toBe(
|
|
170
|
+
'<ul><li class="i"></li></ul>',
|
|
171
|
+
);
|
|
172
|
+
// A `>` inside a quoted value or a partial does not end the tag.
|
|
173
|
+
expect(stripMustache('<a title="x>y" class="{{#on}}on{{/on}}">{{>p}}</a>')).toBe(
|
|
174
|
+
'<a title="x>y" class=""></a>',
|
|
175
|
+
);
|
|
176
|
+
const started = performance.now();
|
|
177
|
+
stripMustache(`<a ${"{{#x}}".repeat(50_000)}>`);
|
|
178
|
+
expect(performance.now() - started).toBeLessThan(500);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("an optional class does not make renders without it free markup (#1104)", () => {
|
|
182
|
+
const tpl =
|
|
183
|
+
'<section class="sec"><div class="wrapc split {{#reverse}}rev{{/reverse}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div></section>';
|
|
184
|
+
const template = parseMarkup(stripMustache(tpl));
|
|
185
|
+
for (const cls of ["wrapc split", "wrapc split rev"]) {
|
|
186
|
+
const rendered = parseMarkup(
|
|
187
|
+
`<section class="sec" data-caelo-module-id="m1"><div class="${cls}"><h2>Hi</h2><div class="body"><p>x</p></div></div></section>`,
|
|
188
|
+
);
|
|
189
|
+
const root = moduleRoots(rendered)[0];
|
|
190
|
+
if (!root) throw new Error("no module root");
|
|
191
|
+
expect(freeRegions(root, template)?.map((n) => n.tag)).toEqual(["p"]);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("a root whose classes all sit in sections is still checked (#1104)", () => {
|
|
196
|
+
const tpl =
|
|
197
|
+
'<div class="{{#moment}}moment{{/moment}}{{^moment}}sec{{/moment}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div>';
|
|
198
|
+
const rendered = parseMarkup(
|
|
199
|
+
`<div class="sec" data-caelo-module-id="m1"><h2>Hi</h2><div class="body">${chain(["a", "b"])}</div></div>`,
|
|
200
|
+
);
|
|
201
|
+
const root = moduleRoots(rendered)[0];
|
|
202
|
+
if (!root) throw new Error("no module root");
|
|
203
|
+
const free = freeRegions(root, parseMarkup(stripMustache(tpl)));
|
|
204
|
+
expect(free).not.toBeNull();
|
|
205
|
+
expect(outermostPatterns(free ?? []).map((f) => f.node.classes[0])).toEqual(["chain"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
161
208
|
it("templateShape fingerprints the template's root element", () => {
|
|
162
209
|
const t = templateShape(
|
|
163
210
|
'<div class="chain">{{#steps}}<div class="step"><span class="n">{{n}}</span><p>{{t}}</p></div><span class="arrow"></span>{{/steps}}</div>',
|
package/src/markup-structure.ts
CHANGED
|
@@ -120,13 +120,84 @@ function splitClasses(raw: string | undefined): string[] {
|
|
|
120
120
|
return [...new Set(raw.split(/\s+/).filter((c) => c.length > 0))].sort();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** Options of {@link stripMustache}. */
|
|
124
|
+
export interface StripMustacheOptions {
|
|
125
|
+
/**
|
|
126
|
+
* Keep the text of a section inside a tag (`class="a {{#s}}b{{/s}}"` →
|
|
127
|
+
* `class="a b"`): every class the template CAN render. Default false:
|
|
128
|
+
* such a section is dropped, leaving the classes it ALWAYS renders.
|
|
129
|
+
*/
|
|
130
|
+
readonly keepOptionalAttributeText?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
/**
|
|
124
134
|
* Remove Mustache tags (`{{x}}`, `{{{x}}}`, `{{#s}}`, `{{/s}}`, `{{>p}}`)
|
|
125
135
|
* from a module template so it parses as the markup it renders: a section
|
|
126
|
-
* body stays once
|
|
136
|
+
* body between elements stays once (a list renders it per entry), a
|
|
137
|
+
* placeholder becomes empty.
|
|
138
|
+
*
|
|
139
|
+
* A section INSIDE a tag (`<div class="split {{#reverse}}rev{{/reverse}}">`,
|
|
140
|
+
* a boolean variant) is optional: the rendered element may or may not
|
|
141
|
+
* carry its text. By default it is dropped, so the template node has only
|
|
142
|
+
* the classes every render has (#1104). Keeping it made `rev` a required
|
|
143
|
+
* class — every render without it looked like free markup — and a root
|
|
144
|
+
* whose classes all sit in sections matched no render, so the module's
|
|
145
|
+
* free markup went unchecked.
|
|
146
|
+
*/
|
|
147
|
+
export function stripMustache(template: string, opts: StripMustacheOptions = {}): string {
|
|
148
|
+
const base = opts.keepOptionalAttributeText ? template : dropInTagSections(template);
|
|
149
|
+
return stripDelimited(stripDelimited(base, 3), 2);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Remove every `{{#s}}…{{/s}}` / `{{^s}}…{{/s}}` that opens inside a tag
|
|
154
|
+
* (between `<name` and its `>`), body included. One linear scan; Mustache
|
|
155
|
+
* tags are skipped whole, so a `>` in `{{>partial}}` never ends a tag.
|
|
127
156
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
157
|
+
function dropInTagSections(text: string): string {
|
|
158
|
+
let out = "";
|
|
159
|
+
let i = 0;
|
|
160
|
+
let inTag = false;
|
|
161
|
+
let quote: string | null = null;
|
|
162
|
+
// Names with no closer after some point have none after any later point
|
|
163
|
+
// either: remembering them keeps the scan linear on unclosed openers.
|
|
164
|
+
const unclosed = new Set<string>();
|
|
165
|
+
while (i < text.length) {
|
|
166
|
+
if (text.startsWith("{{", i)) {
|
|
167
|
+
const end = text.indexOf("}}", i + 2);
|
|
168
|
+
if (end < 0) break;
|
|
169
|
+
const sigil = text[i + 2];
|
|
170
|
+
if (inTag && (sigil === "#" || sigil === "^")) {
|
|
171
|
+
const name = text.slice(i + 3, end).trim();
|
|
172
|
+
const closer = `{{/${name}}}`;
|
|
173
|
+
const close = unclosed.has(name) ? -1 : text.indexOf(closer, end + 2);
|
|
174
|
+
if (close < 0) unclosed.add(name);
|
|
175
|
+
if (name.length > 0 && close >= 0) {
|
|
176
|
+
i = close + closer.length;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
out += text.slice(i, end + 2);
|
|
181
|
+
i = end + 2;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const ch = text[i] as string;
|
|
185
|
+
if (inTag) {
|
|
186
|
+
if (quote !== null) {
|
|
187
|
+
if (ch === quote) quote = null;
|
|
188
|
+
} else if (ch === '"' || ch === "'") {
|
|
189
|
+
quote = ch;
|
|
190
|
+
} else if (ch === ">") {
|
|
191
|
+
inTag = false;
|
|
192
|
+
}
|
|
193
|
+
} else if (ch === "<" && /[a-zA-Z]/.test(text[i + 1] ?? "")) {
|
|
194
|
+
inTag = true;
|
|
195
|
+
quote = null;
|
|
196
|
+
}
|
|
197
|
+
out += ch;
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
return out + text.slice(i);
|
|
130
201
|
}
|
|
131
202
|
|
|
132
203
|
/**
|
package/src/media.ts
CHANGED
|
@@ -394,6 +394,18 @@ export type MediaSetCdnInput = z.infer<typeof mediaSetCdnInputSchema>;
|
|
|
394
394
|
export interface MediaStorageAdapter {
|
|
395
395
|
put(key: string, body: Uint8Array, contentType: string): Promise<void>;
|
|
396
396
|
get(key: string): Promise<Uint8Array>;
|
|
397
|
+
/**
|
|
398
|
+
* #1216 — the object's size in bytes without reading it, so a caller can
|
|
399
|
+
* refuse an oversized object before it is loaded. Throws when the key
|
|
400
|
+
* does not exist.
|
|
401
|
+
*/
|
|
402
|
+
size(key: string): Promise<number>;
|
|
403
|
+
/**
|
|
404
|
+
* #1216 — the object as a byte stream plus its size, for serving it
|
|
405
|
+
* without holding the whole file in memory. Throws when the key does not
|
|
406
|
+
* exist.
|
|
407
|
+
*/
|
|
408
|
+
openStream(key: string): Promise<{ body: ReadableStream<Uint8Array>; sizeBytes: number }>;
|
|
397
409
|
delete(key: string): Promise<void>;
|
|
398
410
|
exists(key: string): Promise<boolean>;
|
|
399
411
|
/** Bytes-on-disk for capacity reporting. */
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { tagModuleId } from "./module-id-tag.js";
|
|
5
|
+
|
|
6
|
+
describe("tagModuleId", () => {
|
|
7
|
+
it("stamps the first opening tag", () => {
|
|
8
|
+
expect(tagModuleId('<p class="a">hi</p>', "m1")).toBe(
|
|
9
|
+
'<p class="a" data-caelo-module-id="m1">hi</p>',
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps a self-closing root self-closed (#1099)", () => {
|
|
14
|
+
expect(tagModuleId('<line x1="0" stroke="#E3E7EE"/>', "m1")).toBe(
|
|
15
|
+
'<line x1="0" stroke="#E3E7EE" data-caelo-module-id="m1"/>',
|
|
16
|
+
);
|
|
17
|
+
expect(tagModuleId('<circle r="2" />', "m1")).toBe('<circle r="2" data-caelo-module-id="m1"/>');
|
|
18
|
+
expect(tagModuleId("<br/>", "m1")).toBe('<br data-caelo-module-id="m1"/>');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not mistake a slash inside a quoted value for self-closing", () => {
|
|
22
|
+
expect(tagModuleId('<a href="/">x</a>', "m1")).toBe(
|
|
23
|
+
'<a href="/" data-caelo-module-id="m1">x</a>',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is idempotent", () => {
|
|
28
|
+
const once = tagModuleId('<polyline points="0,0 1,1"/>', "m1");
|
|
29
|
+
expect(tagModuleId(once, "m2")).toBe(once);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stays linear on a long whitespace run inside the root tag", () => {
|
|
33
|
+
const html = `<div${"\t".repeat(200_000)}x>`;
|
|
34
|
+
const start = performance.now();
|
|
35
|
+
expect(tagModuleId(html, "m1").endsWith(' data-caelo-module-id="m1">')).toBe(true);
|
|
36
|
+
expect(performance.now() - start).toBeLessThan(500);
|
|
37
|
+
});
|
|
38
|
+
});
|