@nebutra/agent-runtime 0.2.0 → 0.2.2
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/LICENSE +21 -676
- package/README.md +2 -0
- package/dist/adapters/dispatcher-sse.js +1 -0
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/mcp-catalog.js +1 -0
- package/dist/adapters/prisma-rollout.js +1 -0
- package/dist/chunk-424PT5DM.js +23 -0
- package/dist/chunk-424PT5DM.js.map +1 -0
- package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
- package/dist/chunk-4Y25ZTKI.js.map +1 -0
- package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
- package/dist/chunk-D4YAPLOW.js.map +1 -0
- package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
- package/dist/chunk-GQZKYWFT.js.map +1 -0
- package/dist/chunk-KCNN4QUQ.js +255 -0
- package/dist/chunk-KCNN4QUQ.js.map +1 -0
- package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
- package/dist/chunk-NI4EDT4T.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
- package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
- package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +52 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.js +1 -0
- package/dist/definitions.js +1 -0
- package/dist/dispatcher.js +1 -0
- package/dist/durable-turn.js +3 -2
- package/dist/hook-pipeline.js +1 -0
- package/dist/index.d.ts +8 -85
- package/dist/index.js +249 -134
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +2 -2
- package/dist/loop.js +3 -2
- package/dist/mcp-bridge.d.ts +3 -3
- package/dist/mcp-bridge.js +3 -2
- package/dist/model.js +1 -0
- package/dist/orchestration.d.ts +84 -0
- package/dist/orchestration.js +16 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/policy.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/pulsar.d.ts +78 -0
- package/dist/pulsar.js +17 -0
- package/dist/pulsar.js.map +1 -0
- package/dist/rollout-store-persistent.js +1 -0
- package/dist/rollout.js +1 -0
- package/dist/sandbox.js +2 -1
- package/dist/skills.js +2 -1
- package/dist/subagents.js +1 -0
- package/dist/tools.d.ts +4 -3
- package/dist/tools.js +5 -3
- package/package.json +84 -27
- package/.turbo/turbo-build.log +0 -115
- package/.turbo/turbo-test.log +0 -44
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -253
- package/dist/chunk-BJBBR3QA.js.map +0 -1
- package/dist/chunk-NN7DATXA.js.map +0 -1
- package/dist/chunk-PGGWSUTM.js.map +0 -1
- package/dist/chunk-ZMYX5VBU.js.map +0 -1
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -434
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -47
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -76
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -200
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -255
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.ts +0 -126
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -33
- /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type DesignContext,
|
|
5
|
-
ingestDesignContext,
|
|
6
|
-
normalizeScrapeResult,
|
|
7
|
-
type RawScrapeResult,
|
|
8
|
-
type ScrapeProvider,
|
|
9
|
-
toGenerationSeed,
|
|
10
|
-
} from "./design-context.js";
|
|
11
|
-
|
|
12
|
-
const FIXED_ISO = "2026-05-17T00:00:00.000Z";
|
|
13
|
-
const fixedClock = () => new Date(FIXED_ISO);
|
|
14
|
-
|
|
15
|
-
function fakeProvider(result: RawScrapeResult): ScrapeProvider & {
|
|
16
|
-
calls: Array<{ url: string; formats: string[] }>;
|
|
17
|
-
} {
|
|
18
|
-
const calls: Array<{ url: string; formats: string[] }> = [];
|
|
19
|
-
return {
|
|
20
|
-
calls,
|
|
21
|
-
async scrape(url, opts) {
|
|
22
|
-
calls.push({ url, formats: [...opts.formats] });
|
|
23
|
-
return result;
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("normalizeScrapeResult", () => {
|
|
29
|
-
it("prefers markdown over html for content", () => {
|
|
30
|
-
const ctx = normalizeScrapeResult(
|
|
31
|
-
"org_1",
|
|
32
|
-
"https://example.com",
|
|
33
|
-
{ markdown: "# Hello MD", html: "<h1>Hello HTML</h1>" },
|
|
34
|
-
{ clock: fixedClock },
|
|
35
|
-
);
|
|
36
|
-
expect(ctx.content).toBe("# Hello MD");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("strips tags from html when markdown absent", () => {
|
|
40
|
-
const ctx = normalizeScrapeResult(
|
|
41
|
-
"org_1",
|
|
42
|
-
"https://example.com",
|
|
43
|
-
{ html: "<div><h1>Title</h1><p>Body & more</p></div>" },
|
|
44
|
-
{ clock: fixedClock },
|
|
45
|
-
);
|
|
46
|
-
expect(ctx.content).not.toContain("<");
|
|
47
|
-
expect(ctx.content).toContain("Title");
|
|
48
|
-
expect(ctx.content).toContain("Body");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("dedups and validates colors, drops junk", () => {
|
|
52
|
-
const ctx = normalizeScrapeResult(
|
|
53
|
-
"org_1",
|
|
54
|
-
"https://example.com",
|
|
55
|
-
{
|
|
56
|
-
markdown: "x",
|
|
57
|
-
branding: {
|
|
58
|
-
colors: ["#fff", "#fff", "#FFFFFF", "rgb(0,0,0)", "not-a-color", "", " "],
|
|
59
|
-
fonts: ["Inter", "Inter", " Roboto ", ""],
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
{ clock: fixedClock },
|
|
63
|
-
);
|
|
64
|
-
expect(ctx.brand.colors).toEqual(["#fff", "#FFFFFF", "rgb(0,0,0)"]);
|
|
65
|
-
expect(ctx.brand.fonts).toEqual(["Inter", "Roboto"]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("degrades gracefully on empty raw (no throw, empty brand)", () => {
|
|
69
|
-
const ctx = normalizeScrapeResult("org_1", "https://example.com", {}, { clock: fixedClock });
|
|
70
|
-
expect(ctx.content).toBe("");
|
|
71
|
-
expect(ctx.brand.colors).toEqual([]);
|
|
72
|
-
expect(ctx.brand.fonts).toEqual([]);
|
|
73
|
-
expect(ctx.screenshotRef).toBeUndefined();
|
|
74
|
-
expect(ctx.title).toBeUndefined();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("passes screenshot and title through", () => {
|
|
78
|
-
const ctx = normalizeScrapeResult(
|
|
79
|
-
"org_1",
|
|
80
|
-
"https://example.com",
|
|
81
|
-
{ markdown: "x", screenshot: "https://cdn/shot.png", metadata: { title: "My Site" } },
|
|
82
|
-
{ clock: fixedClock },
|
|
83
|
-
);
|
|
84
|
-
expect(ctx.screenshotRef).toBe("https://cdn/shot.png");
|
|
85
|
-
expect(ctx.title).toBe("My Site");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("treats null screenshot as undefined", () => {
|
|
89
|
-
const ctx = normalizeScrapeResult(
|
|
90
|
-
"org_1",
|
|
91
|
-
"https://example.com",
|
|
92
|
-
{ markdown: "x", screenshot: null },
|
|
93
|
-
{ clock: fixedClock },
|
|
94
|
-
);
|
|
95
|
-
expect(ctx.screenshotRef).toBeUndefined();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("uses injectable clock for fetchedAt", () => {
|
|
99
|
-
const ctx = normalizeScrapeResult(
|
|
100
|
-
"org_1",
|
|
101
|
-
"https://example.com",
|
|
102
|
-
{ markdown: "x" },
|
|
103
|
-
{
|
|
104
|
-
clock: fixedClock,
|
|
105
|
-
},
|
|
106
|
-
);
|
|
107
|
-
expect(ctx.fetchedAt).toBe(FIXED_ISO);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("carries tenantId through", () => {
|
|
111
|
-
const ctx = normalizeScrapeResult(
|
|
112
|
-
"org_xyz",
|
|
113
|
-
"https://example.com",
|
|
114
|
-
{ markdown: "x" },
|
|
115
|
-
{
|
|
116
|
-
clock: fixedClock,
|
|
117
|
-
},
|
|
118
|
-
);
|
|
119
|
-
expect(ctx.tenantId).toBe("org_xyz");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("fails closed on empty tenantId", () => {
|
|
123
|
-
expect(() =>
|
|
124
|
-
normalizeScrapeResult("", "https://example.com", { markdown: "x" }, { clock: fixedClock }),
|
|
125
|
-
).toThrow();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("fails closed on empty url", () => {
|
|
129
|
-
expect(() =>
|
|
130
|
-
normalizeScrapeResult("org_1", "", { markdown: "x" }, { clock: fixedClock }),
|
|
131
|
-
).toThrow();
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe("ingestDesignContext", () => {
|
|
136
|
-
it("delegates to the injected provider (no real network)", async () => {
|
|
137
|
-
const provider = fakeProvider({ markdown: "seed", metadata: { title: "T" } });
|
|
138
|
-
const ctx = await ingestDesignContext("org_1", "https://example.com", provider, {
|
|
139
|
-
clock: fixedClock,
|
|
140
|
-
});
|
|
141
|
-
expect(provider.calls).toHaveLength(1);
|
|
142
|
-
expect(provider.calls[0]?.url).toBe("https://example.com");
|
|
143
|
-
expect(ctx.content).toBe("seed");
|
|
144
|
-
expect(ctx.title).toBe("T");
|
|
145
|
-
expect(ctx.tenantId).toBe("org_1");
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("passes requested formats to provider", async () => {
|
|
149
|
-
const provider = fakeProvider({ markdown: "x" });
|
|
150
|
-
await ingestDesignContext("org_1", "https://example.com", provider, {
|
|
151
|
-
formats: ["markdown", "branding"],
|
|
152
|
-
clock: fixedClock,
|
|
153
|
-
});
|
|
154
|
-
expect(provider.calls[0]?.formats).toEqual(["markdown", "branding"]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("fails closed on empty tenantId", async () => {
|
|
158
|
-
const provider = fakeProvider({ markdown: "x" });
|
|
159
|
-
await expect(
|
|
160
|
-
ingestDesignContext("", "https://example.com", provider, { clock: fixedClock }),
|
|
161
|
-
).rejects.toThrow();
|
|
162
|
-
expect(provider.calls).toHaveLength(0);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("toGenerationSeed", () => {
|
|
167
|
-
const base: DesignContext = {
|
|
168
|
-
tenantId: "org_1",
|
|
169
|
-
sourceUrl: "https://example.com",
|
|
170
|
-
content: "Lorem ipsum dolor sit amet ".repeat(500),
|
|
171
|
-
brand: { colors: ["#fff", "rgb(0,0,0)"], fonts: ["Inter"] },
|
|
172
|
-
screenshotRef: undefined,
|
|
173
|
-
title: "Example",
|
|
174
|
-
fetchedAt: FIXED_ISO,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
it("is deterministic", () => {
|
|
178
|
-
expect(toGenerationSeed(base)).toBe(toGenerationSeed(base));
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("is length-bounded by default", () => {
|
|
182
|
-
expect(toGenerationSeed(base).length).toBeLessThanOrEqual(4000);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("respects injectable max chars", () => {
|
|
186
|
-
expect(toGenerationSeed(base, { maxChars: 200 }).length).toBeLessThanOrEqual(200);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("includes title, palette and fonts", () => {
|
|
190
|
-
const seed = toGenerationSeed({ ...base, content: "short" });
|
|
191
|
-
expect(seed).toContain("Example");
|
|
192
|
-
expect(seed).toContain("#fff");
|
|
193
|
-
expect(seed).toContain("Inter");
|
|
194
|
-
});
|
|
195
|
-
});
|
package/src/design-context.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Design-context ingestion: turn an existing website into a structured
|
|
5
|
-
* generation seed for a codegen turn.
|
|
6
|
-
*
|
|
7
|
-
* The scrape implementation is provider-specific and injected via a port;
|
|
8
|
-
* this module never performs network I/O itself.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** Structured generation seed extracted from an existing site. */
|
|
12
|
-
export interface DesignContext {
|
|
13
|
-
tenantId: string;
|
|
14
|
-
sourceUrl: string;
|
|
15
|
-
content: string;
|
|
16
|
-
brand: {
|
|
17
|
-
colors: string[];
|
|
18
|
-
fonts: string[];
|
|
19
|
-
};
|
|
20
|
-
screenshotRef?: string | undefined;
|
|
21
|
-
title?: string | undefined;
|
|
22
|
-
fetchedAt: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Loose shape returned by a scrape provider (providers vary widely). */
|
|
26
|
-
export interface RawScrapeResult {
|
|
27
|
-
markdown?: string | undefined;
|
|
28
|
-
html?: string | undefined;
|
|
29
|
-
screenshot?: string | null | undefined;
|
|
30
|
-
metadata?: { title?: string | undefined } | undefined;
|
|
31
|
-
branding?:
|
|
32
|
-
| {
|
|
33
|
-
colors?: string[] | undefined;
|
|
34
|
-
fonts?: string[] | undefined;
|
|
35
|
-
}
|
|
36
|
-
| undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type ScrapeFormat = "markdown" | "screenshot" | "branding";
|
|
40
|
-
|
|
41
|
-
/** Injected provider port — callers wire a concrete scrape implementation. */
|
|
42
|
-
export interface ScrapeProvider {
|
|
43
|
-
scrape(url: string, opts: { formats: ScrapeFormat[] }): Promise<RawScrapeResult>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Clock injection for deterministic tests. */
|
|
47
|
-
export type Clock = () => Date;
|
|
48
|
-
|
|
49
|
-
const tenantSchema = z.string().trim().min(1, "tenantId is required");
|
|
50
|
-
const urlSchema = z.string().trim().min(1, "sourceUrl is required");
|
|
51
|
-
|
|
52
|
-
const DEFAULT_FORMATS: readonly ScrapeFormat[] = ["markdown", "screenshot", "branding"];
|
|
53
|
-
const DEFAULT_MAX_CHARS = 4000;
|
|
54
|
-
|
|
55
|
-
/** Permissive colour matcher: hex (3/4/6/8) or rgb/rgba/hsl(...)-ish. */
|
|
56
|
-
const COLOR_RE = /^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|rgba|hsl|hsla)\([^)]+\))$/i;
|
|
57
|
-
|
|
58
|
-
function stripTags(html: string): string {
|
|
59
|
-
const withoutTags = html.replace(/<[^>]*>/g, " ");
|
|
60
|
-
const decoded = withoutTags
|
|
61
|
-
.replace(/&/g, "&")
|
|
62
|
-
.replace(/</g, "<")
|
|
63
|
-
.replace(/>/g, ">")
|
|
64
|
-
.replace(/"/g, '"')
|
|
65
|
-
.replace(/'/g, "'")
|
|
66
|
-
.replace(/ /g, " ");
|
|
67
|
-
return decoded.replace(/\s+/g, " ").trim();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function dedupe(values: readonly string[]): string[] {
|
|
71
|
-
const seen = new Set<string>();
|
|
72
|
-
const out: string[] = [];
|
|
73
|
-
for (const v of values) {
|
|
74
|
-
if (!seen.has(v)) {
|
|
75
|
-
seen.add(v);
|
|
76
|
-
out.push(v);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function normalizeColors(colors: readonly string[] | undefined): string[] {
|
|
83
|
-
if (!colors) return [];
|
|
84
|
-
const cleaned = colors
|
|
85
|
-
.map((c) => (typeof c === "string" ? c.trim() : ""))
|
|
86
|
-
.filter((c) => c.length > 0 && COLOR_RE.test(c));
|
|
87
|
-
return dedupe(cleaned);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function normalizeFonts(fonts: readonly string[] | undefined): string[] {
|
|
91
|
-
if (!fonts) return [];
|
|
92
|
-
const cleaned = fonts
|
|
93
|
-
.map((f) => (typeof f === "string" ? f.trim() : ""))
|
|
94
|
-
.filter((f) => f.length > 0);
|
|
95
|
-
return dedupe(cleaned);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function resolveContent(raw: RawScrapeResult): string {
|
|
99
|
-
if (typeof raw.markdown === "string" && raw.markdown.length > 0) {
|
|
100
|
-
return raw.markdown;
|
|
101
|
-
}
|
|
102
|
-
if (typeof raw.html === "string" && raw.html.length > 0) {
|
|
103
|
-
return stripTags(raw.html);
|
|
104
|
-
}
|
|
105
|
-
return "";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Pure transform from a loose provider result to a {@link DesignContext}.
|
|
110
|
-
* Never throws on missing fields — degrades gracefully. Fails closed on
|
|
111
|
-
* empty tenantId / url.
|
|
112
|
-
*/
|
|
113
|
-
export function normalizeScrapeResult(
|
|
114
|
-
tenantId: string,
|
|
115
|
-
url: string,
|
|
116
|
-
raw: RawScrapeResult,
|
|
117
|
-
opts?: { clock?: Clock | undefined },
|
|
118
|
-
): DesignContext {
|
|
119
|
-
const safeTenant = tenantSchema.parse(tenantId);
|
|
120
|
-
const safeUrl = urlSchema.parse(url);
|
|
121
|
-
const clock = opts?.clock ?? (() => new Date());
|
|
122
|
-
|
|
123
|
-
const screenshot =
|
|
124
|
-
typeof raw.screenshot === "string" && raw.screenshot.length > 0 ? raw.screenshot : undefined;
|
|
125
|
-
const title =
|
|
126
|
-
typeof raw.metadata?.title === "string" && raw.metadata.title.length > 0
|
|
127
|
-
? raw.metadata.title
|
|
128
|
-
: undefined;
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
tenantId: safeTenant,
|
|
132
|
-
sourceUrl: safeUrl,
|
|
133
|
-
content: resolveContent(raw),
|
|
134
|
-
brand: {
|
|
135
|
-
colors: normalizeColors(raw.branding?.colors),
|
|
136
|
-
fonts: normalizeFonts(raw.branding?.fonts),
|
|
137
|
-
},
|
|
138
|
-
screenshotRef: screenshot,
|
|
139
|
-
title,
|
|
140
|
-
fetchedAt: clock().toISOString(),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Validate inputs, call the injected provider, normalize the result.
|
|
146
|
-
* tenantId is mandatory and validated before the provider is invoked
|
|
147
|
-
* (fail-closed: no provider call for an invalid tenant).
|
|
148
|
-
*/
|
|
149
|
-
export async function ingestDesignContext(
|
|
150
|
-
tenantId: string,
|
|
151
|
-
url: string,
|
|
152
|
-
provider: ScrapeProvider,
|
|
153
|
-
opts?: {
|
|
154
|
-
formats?: ScrapeFormat[] | undefined;
|
|
155
|
-
clock?: Clock | undefined;
|
|
156
|
-
},
|
|
157
|
-
): Promise<DesignContext> {
|
|
158
|
-
const safeTenant = tenantSchema.parse(tenantId);
|
|
159
|
-
const safeUrl = urlSchema.parse(url);
|
|
160
|
-
const formats = opts?.formats ?? [...DEFAULT_FORMATS];
|
|
161
|
-
const raw = await provider.scrape(safeUrl, { formats });
|
|
162
|
-
return normalizeScrapeResult(safeTenant, safeUrl, raw, {
|
|
163
|
-
clock: opts?.clock,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Render a compact, deterministic prompt-seed block suitable for
|
|
169
|
-
* prepending to a codegen turn. Pure; output length is bounded.
|
|
170
|
-
*/
|
|
171
|
-
export function toGenerationSeed(
|
|
172
|
-
ctx: DesignContext,
|
|
173
|
-
opts?: { maxChars?: number | undefined },
|
|
174
|
-
): string {
|
|
175
|
-
const maxChars =
|
|
176
|
-
typeof opts?.maxChars === "number" && opts.maxChars > 0 ? opts.maxChars : DEFAULT_MAX_CHARS;
|
|
177
|
-
|
|
178
|
-
const header: string[] = ["## Design Context", `Source: ${ctx.sourceUrl}`];
|
|
179
|
-
if (ctx.title) header.push(`Title: ${ctx.title}`);
|
|
180
|
-
if (ctx.brand.colors.length > 0) {
|
|
181
|
-
header.push(`Palette: ${ctx.brand.colors.join(", ")}`);
|
|
182
|
-
}
|
|
183
|
-
if (ctx.brand.fonts.length > 0) {
|
|
184
|
-
header.push(`Fonts: ${ctx.brand.fonts.join(", ")}`);
|
|
185
|
-
}
|
|
186
|
-
if (ctx.screenshotRef) header.push(`Screenshot: ${ctx.screenshotRef}`);
|
|
187
|
-
|
|
188
|
-
const headerBlock = header.join("\n");
|
|
189
|
-
const contentLabel = "\n\nContent:\n";
|
|
190
|
-
const budget = maxChars - headerBlock.length - contentLabel.length;
|
|
191
|
-
|
|
192
|
-
if (budget <= 0) {
|
|
193
|
-
return headerBlock.slice(0, maxChars);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const truncated = ctx.content.slice(0, budget);
|
|
197
|
-
return `${headerBlock}${contentLabel}${truncated}`;
|
|
198
|
-
}
|
package/src/dispatcher.test.ts
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ProtocolDispatcher } from "./dispatcher";
|
|
3
|
-
|
|
4
|
-
function envelope(over: Record<string, unknown> = {}) {
|
|
5
|
-
return {
|
|
6
|
-
jsonrpc: "2.0",
|
|
7
|
-
id: 1,
|
|
8
|
-
method: "model/list",
|
|
9
|
-
tenantId: "org_123",
|
|
10
|
-
...over,
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("ProtocolDispatcher — envelope validation (fail-closed)", () => {
|
|
15
|
-
it("rejects an envelope missing tenantId with a JSON-RPC error (never throws)", async () => {
|
|
16
|
-
const d = new ProtocolDispatcher();
|
|
17
|
-
const res = await d.dispatch({ jsonrpc: "2.0", id: 7, method: "model/list" });
|
|
18
|
-
expect(res).toMatchObject({ jsonrpc: "2.0", id: 7 });
|
|
19
|
-
expect("error" in res && res.error.code).toBe(-32600);
|
|
20
|
-
expect("result" in res).toBe(false);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("rejects an empty tenantId fail-closed", async () => {
|
|
24
|
-
const d = new ProtocolDispatcher();
|
|
25
|
-
const res = await d.dispatch(envelope({ tenantId: "" }));
|
|
26
|
-
expect("error" in res && res.error.code).toBe(-32600);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("rejects a completely malformed envelope without throwing", async () => {
|
|
30
|
-
const d = new ProtocolDispatcher();
|
|
31
|
-
const res = await d.dispatch("not-an-object");
|
|
32
|
-
expect("error" in res && res.error.code).toBe(-32600);
|
|
33
|
-
// id falls back to null when unparseable
|
|
34
|
-
expect((res as { id: unknown }).id).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("ProtocolDispatcher — method lookup", () => {
|
|
39
|
-
it("returns -32601 for an unknown method", async () => {
|
|
40
|
-
const d = new ProtocolDispatcher();
|
|
41
|
-
const res = await d.dispatch(envelope({ method: "does/not-exist" }));
|
|
42
|
-
expect("error" in res && res.error.code).toBe(-32601);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("returns -32601 when no handler is registered for a known method", async () => {
|
|
46
|
-
const d = new ProtocolDispatcher();
|
|
47
|
-
const res = await d.dispatch(envelope({ method: "model/list" }));
|
|
48
|
-
expect("error" in res && res.error.code).toBe(-32601);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("invokes a registered handler and returns its result", async () => {
|
|
52
|
-
const d = new ProtocolDispatcher();
|
|
53
|
-
d.register("model/list", async () => ({ models: ["a"] }));
|
|
54
|
-
const res = await d.dispatch(envelope());
|
|
55
|
-
expect(res).toMatchObject({
|
|
56
|
-
jsonrpc: "2.0",
|
|
57
|
-
id: 1,
|
|
58
|
-
result: { models: ["a"] },
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("ProtocolDispatcher — scope resolution (fail-closed)", () => {
|
|
64
|
-
it("returns a JSON-RPC error when a thread-scoped method is missing threadId", async () => {
|
|
65
|
-
const d = new ProtocolDispatcher();
|
|
66
|
-
d.register("turn/start", async () => "ok");
|
|
67
|
-
const res = await d.dispatch(envelope({ method: "turn/start", params: {} }));
|
|
68
|
-
expect("error" in res && res.error.code).toBe(-32602);
|
|
69
|
-
expect("result" in res).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("passes the resolved thread scope to the handler", async () => {
|
|
73
|
-
const d = new ProtocolDispatcher();
|
|
74
|
-
let seenScope: unknown;
|
|
75
|
-
d.register("turn/start", async (_p, scope) => {
|
|
76
|
-
seenScope = scope;
|
|
77
|
-
return "ok";
|
|
78
|
-
});
|
|
79
|
-
await d.dispatch(envelope({ method: "turn/start", params: { threadId: "th_1" } }));
|
|
80
|
-
expect(seenScope).toEqual({
|
|
81
|
-
tenantId: "org_123",
|
|
82
|
-
kind: "thread",
|
|
83
|
-
threadId: "th_1",
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("ProtocolDispatcher — serialization semantics", () => {
|
|
89
|
-
it("serializes same-scope requests in FIFO order", async () => {
|
|
90
|
-
const d = new ProtocolDispatcher();
|
|
91
|
-
const order: string[] = [];
|
|
92
|
-
d.register("turn/start", async (params) => {
|
|
93
|
-
const p = params as { tag: string; delay: number };
|
|
94
|
-
await new Promise((r) => setTimeout(r, p.delay));
|
|
95
|
-
order.push(p.tag);
|
|
96
|
-
return p.tag;
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const a = d.dispatch(
|
|
100
|
-
envelope({
|
|
101
|
-
method: "turn/start",
|
|
102
|
-
id: "a",
|
|
103
|
-
params: { threadId: "th_same", tag: "A", delay: 40 },
|
|
104
|
-
}),
|
|
105
|
-
);
|
|
106
|
-
const b = d.dispatch(
|
|
107
|
-
envelope({
|
|
108
|
-
method: "turn/start",
|
|
109
|
-
id: "b",
|
|
110
|
-
params: { threadId: "th_same", tag: "B", delay: 1 },
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
await Promise.all([a, b]);
|
|
115
|
-
// B is fast but must wait for A because they share tenant+thread scope.
|
|
116
|
-
expect(order).toEqual(["A", "B"]);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("runs distinct-scope requests concurrently", async () => {
|
|
120
|
-
const d = new ProtocolDispatcher();
|
|
121
|
-
const order: string[] = [];
|
|
122
|
-
d.register("turn/start", async (params) => {
|
|
123
|
-
const p = params as { tag: string; delay: number };
|
|
124
|
-
await new Promise((r) => setTimeout(r, p.delay));
|
|
125
|
-
order.push(p.tag);
|
|
126
|
-
return p.tag;
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const slow = d.dispatch(
|
|
130
|
-
envelope({
|
|
131
|
-
method: "turn/start",
|
|
132
|
-
id: "slow",
|
|
133
|
-
params: { threadId: "th_slow", tag: "SLOW", delay: 40 },
|
|
134
|
-
}),
|
|
135
|
-
);
|
|
136
|
-
const fast = d.dispatch(
|
|
137
|
-
envelope({
|
|
138
|
-
method: "turn/start",
|
|
139
|
-
id: "fast",
|
|
140
|
-
params: { threadId: "th_fast", tag: "FAST", delay: 1 },
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
await Promise.all([slow, fast]);
|
|
145
|
-
// Different threads → different scope keys → concurrent; FAST finishes first.
|
|
146
|
-
expect(order).toEqual(["FAST", "SLOW"]);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("isolates same-thread ids across different tenants (cross-tenant concurrency)", async () => {
|
|
150
|
-
const d = new ProtocolDispatcher();
|
|
151
|
-
const order: string[] = [];
|
|
152
|
-
d.register("turn/start", async (params) => {
|
|
153
|
-
const p = params as { tag: string; delay: number };
|
|
154
|
-
await new Promise((r) => setTimeout(r, p.delay));
|
|
155
|
-
order.push(p.tag);
|
|
156
|
-
return p.tag;
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const t1 = d.dispatch(
|
|
160
|
-
envelope({
|
|
161
|
-
tenantId: "org_A",
|
|
162
|
-
method: "turn/start",
|
|
163
|
-
id: "t1",
|
|
164
|
-
params: { threadId: "th_shared", tag: "A", delay: 40 },
|
|
165
|
-
}),
|
|
166
|
-
);
|
|
167
|
-
const t2 = d.dispatch(
|
|
168
|
-
envelope({
|
|
169
|
-
tenantId: "org_B",
|
|
170
|
-
method: "turn/start",
|
|
171
|
-
id: "t2",
|
|
172
|
-
params: { threadId: "th_shared", tag: "B", delay: 1 },
|
|
173
|
-
}),
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
await Promise.all([t1, t2]);
|
|
177
|
-
expect(order).toEqual(["B", "A"]);
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
describe("ProtocolDispatcher — handler errors", () => {
|
|
182
|
-
it("maps a thrown handler error to JSON-RPC -32603 without leaking a stack", async () => {
|
|
183
|
-
const d = new ProtocolDispatcher();
|
|
184
|
-
d.register("model/list", async () => {
|
|
185
|
-
throw new Error("boom internal");
|
|
186
|
-
});
|
|
187
|
-
const res = await d.dispatch(envelope());
|
|
188
|
-
expect("error" in res && res.error.code).toBe(-32603);
|
|
189
|
-
const msg = ("error" in res && res.error.message) || "";
|
|
190
|
-
expect(msg).toBe("boom internal");
|
|
191
|
-
expect(msg).not.toContain("at ");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("recovers the serial lane after a handler throws", async () => {
|
|
195
|
-
const d = new ProtocolDispatcher();
|
|
196
|
-
let calls = 0;
|
|
197
|
-
d.register("turn/start", async () => {
|
|
198
|
-
calls += 1;
|
|
199
|
-
if (calls === 1) throw new Error("first fails");
|
|
200
|
-
return "second ok";
|
|
201
|
-
});
|
|
202
|
-
const first = await d.dispatch(
|
|
203
|
-
envelope({ method: "turn/start", id: "f1", params: { threadId: "th_x" } }),
|
|
204
|
-
);
|
|
205
|
-
const second = await d.dispatch(
|
|
206
|
-
envelope({ method: "turn/start", id: "f2", params: { threadId: "th_x" } }),
|
|
207
|
-
);
|
|
208
|
-
expect("error" in first && first.error.code).toBe(-32603);
|
|
209
|
-
expect("result" in second && second.result).toBe("second ok");
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe("ProtocolDispatcher — notifications", () => {
|
|
214
|
-
it("emits to registered listeners for a valid notification name", () => {
|
|
215
|
-
const d = new ProtocolDispatcher();
|
|
216
|
-
const spy = vi.fn();
|
|
217
|
-
d.onNotification("turn/completed", spy);
|
|
218
|
-
d.emitNotification("turn/completed", { turnId: "t1" });
|
|
219
|
-
expect(spy).toHaveBeenCalledWith({ turnId: "t1" });
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("throws for an unknown notification name (validate name ∈ NOTIFICATIONS)", () => {
|
|
223
|
-
const d = new ProtocolDispatcher();
|
|
224
|
-
expect(() => d.emitNotification("not/a/real/notification" as never, {})).toThrow();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("does not invoke listeners bound to a different notification", () => {
|
|
228
|
-
const d = new ProtocolDispatcher();
|
|
229
|
-
const spy = vi.fn();
|
|
230
|
-
d.onNotification("turn/started", spy);
|
|
231
|
-
d.emitNotification("turn/completed", {});
|
|
232
|
-
expect(spy).not.toHaveBeenCalled();
|
|
233
|
-
});
|
|
234
|
-
});
|