@ogygie/studio 0.19.0 → 0.21.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/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/server/start.ts
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { dirname as dirname2, join as join2 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -38,24 +38,167 @@ function throwOgygieError(input, httpStatus = 500) {
|
|
|
38
38
|
throw new OgygieFailure(ogygieError(input), httpStatus);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// src/server/storage/local-adapter.ts
|
|
42
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
43
|
-
import { dirname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
44
|
-
|
|
45
41
|
// ../ogygie-core/src/repo-adapter.ts
|
|
46
42
|
import { readFile as readFileFromDisk, readdir, realpath, stat } from "node:fs/promises";
|
|
47
43
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
48
44
|
import { z as z2 } from "zod";
|
|
45
|
+
|
|
46
|
+
// ../ogygie-core/src/repo-adapter-github.ts
|
|
47
|
+
var NEVER_LISTED = /* @__PURE__ */ new Set(["node_modules"]);
|
|
48
|
+
function isSkippedSegment(segment) {
|
|
49
|
+
return segment.startsWith(".") || NEVER_LISTED.has(segment);
|
|
50
|
+
}
|
|
51
|
+
function refusePathOutsideRepo(relativePath, cause) {
|
|
52
|
+
return throwOgygieError(
|
|
53
|
+
{
|
|
54
|
+
code: "PATH_OUTSIDE_REPO",
|
|
55
|
+
message: "The requested path resolves outside the repo root.",
|
|
56
|
+
file: relativePath,
|
|
57
|
+
cause,
|
|
58
|
+
suggestedFix: "Address a path INSIDE the repo, written relative to its root and without `..`."
|
|
59
|
+
},
|
|
60
|
+
400
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
function normalizeRepoPath(relativePath) {
|
|
64
|
+
if (relativePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(relativePath)) {
|
|
65
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
|
|
66
|
+
}
|
|
67
|
+
const segments = relativePath.split(/[\\/]+/).filter((segment) => segment !== "" && segment !== ".");
|
|
68
|
+
if (segments.includes("..")) {
|
|
69
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" climbs out of the repo with ".."`);
|
|
70
|
+
}
|
|
71
|
+
return segments.join("/");
|
|
72
|
+
}
|
|
73
|
+
function createGithubApiRepoAdapter(options) {
|
|
74
|
+
const apiBase = options.apiBase ?? "https://api.github.com";
|
|
75
|
+
const repoUrl = `${apiBase}/repos/${options.owner}/${options.repo}`;
|
|
76
|
+
const where = `${options.owner}/${options.repo}@${options.ref}`;
|
|
77
|
+
async function request(url, allowNotFound) {
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await options.fetchImpl(url, {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: {
|
|
83
|
+
accept: "application/vnd.github+json",
|
|
84
|
+
"x-github-api-version": "2022-11-28",
|
|
85
|
+
"user-agent": options.userAgent ?? "ogygie-core",
|
|
86
|
+
...options.token === void 0 ? {} : { authorization: `Bearer ${options.token}` }
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
} catch (thrown) {
|
|
90
|
+
return throwOgygieError({
|
|
91
|
+
code: "GITHUB_UNAVAILABLE",
|
|
92
|
+
message: `GitHub could not be reached to read ${where}.`,
|
|
93
|
+
file: null,
|
|
94
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
95
|
+
suggestedFix: "Retry once the network is back \u2014 a read has no side effect to undo."
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 404 && allowNotFound) return null;
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return throwOgygieError({
|
|
101
|
+
code: "GITHUB_UNAVAILABLE",
|
|
102
|
+
message: `GitHub answered ${response.status} for GET ${url.replace(apiBase, "")}.`,
|
|
103
|
+
file: null,
|
|
104
|
+
cause: `status=${response.status}`,
|
|
105
|
+
suggestedFix: response.status === 401 || response.status === 403 ? "Check the GitHub token: it must grant contents (read) on this repo." : "Retry later \u2014 a read has no side effect to undo."
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return await response.json();
|
|
109
|
+
}
|
|
110
|
+
let commitShaPromise = null;
|
|
111
|
+
let treePromise = null;
|
|
112
|
+
function commitSha() {
|
|
113
|
+
commitShaPromise ??= (async () => {
|
|
114
|
+
const commit = await request(
|
|
115
|
+
`${repoUrl}/commits/${encodeURIComponent(options.ref)}`,
|
|
116
|
+
true
|
|
117
|
+
);
|
|
118
|
+
if (commit === null) {
|
|
119
|
+
return throwOgygieError({
|
|
120
|
+
code: "GITHUB_UNAVAILABLE",
|
|
121
|
+
message: `Ref "${options.ref}" does not exist on ${options.owner}/${options.repo}.`,
|
|
122
|
+
file: null,
|
|
123
|
+
cause: "the commits endpoint answered 404 for that ref",
|
|
124
|
+
suggestedFix: "Name an existing branch, tag or commit SHA of that repo."
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return commit.sha;
|
|
128
|
+
})();
|
|
129
|
+
return commitShaPromise;
|
|
130
|
+
}
|
|
131
|
+
function tree() {
|
|
132
|
+
treePromise ??= (async () => {
|
|
133
|
+
const sha = await commitSha();
|
|
134
|
+
const answer = await request(
|
|
135
|
+
`${repoUrl}/git/trees/${sha}?recursive=1`,
|
|
136
|
+
false
|
|
137
|
+
);
|
|
138
|
+
if (answer === null) return [];
|
|
139
|
+
if (answer.truncated) {
|
|
140
|
+
return throwOgygieError({
|
|
141
|
+
code: "REPO_TREE_TRUNCATED",
|
|
142
|
+
message: `The tree of ${where} is too large for one GitHub answer.`,
|
|
143
|
+
file: null,
|
|
144
|
+
cause: "the trees endpoint set truncated=true",
|
|
145
|
+
suggestedFix: "Read that repo from a clone (createFilesystemRepoAdapter) rather than through the API."
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return answer.tree.filter(
|
|
149
|
+
(entry) => entry.type === "blob" && !entry.path.split("/").slice(0, -1).some(isSkippedSegment)
|
|
150
|
+
);
|
|
151
|
+
})();
|
|
152
|
+
return treePromise;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
async readFile(relativePath) {
|
|
156
|
+
const path = normalizeRepoPath(relativePath);
|
|
157
|
+
const sha = await commitSha();
|
|
158
|
+
const file = await request(`${repoUrl}/contents/${path}?ref=${sha}`, true);
|
|
159
|
+
if (file === null || Array.isArray(file) || file.type !== "file") {
|
|
160
|
+
return throwOgygieError({
|
|
161
|
+
code: "REPO_FILE_NOT_FOUND",
|
|
162
|
+
message: "The requested file cannot be read from the repo.",
|
|
163
|
+
file: relativePath,
|
|
164
|
+
cause: file === null ? `no file at "${path}" in ${where}` : `"${path}" is not a file in ${where}`,
|
|
165
|
+
suggestedFix: "Check the path against what the map declares \u2014 the manifest is the only chart of a repo."
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (file.encoding === "base64" && file.content !== void 0) {
|
|
169
|
+
return Buffer.from(file.content, "base64").toString("utf8");
|
|
170
|
+
}
|
|
171
|
+
const blob = await request(`${repoUrl}/git/blobs/${file.sha}`, false);
|
|
172
|
+
return Buffer.from(blob?.content ?? "", "base64").toString("utf8");
|
|
173
|
+
},
|
|
174
|
+
async listDirectory(relativePath) {
|
|
175
|
+
const path = normalizeRepoPath(relativePath);
|
|
176
|
+
const prefix = path === "" ? "" : `${path}/`;
|
|
177
|
+
return (await tree()).filter((entry) => entry.path.startsWith(prefix)).map((entry) => ({ path: entry.path.slice(prefix.length), sizeBytes: entry.size ?? 0 })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
178
|
+
},
|
|
179
|
+
async exists(relativePath) {
|
|
180
|
+
const path = normalizeRepoPath(relativePath);
|
|
181
|
+
if (path === "") return true;
|
|
182
|
+
const prefix = `${path}/`;
|
|
183
|
+
return (await tree()).some((entry) => entry.path === path || entry.path.startsWith(prefix));
|
|
184
|
+
},
|
|
185
|
+
async currentRef() {
|
|
186
|
+
return commitSha();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ../ogygie-core/src/repo-adapter.ts
|
|
49
192
|
var RepoFileEntrySchema = z2.object({
|
|
50
193
|
/** Path relative to the LISTED directory, "/" separated, never absolute. */
|
|
51
194
|
path: z2.string(),
|
|
52
195
|
sizeBytes: z2.number().int().nonnegative()
|
|
53
196
|
});
|
|
54
|
-
var
|
|
197
|
+
var NEVER_LISTED2 = /* @__PURE__ */ new Set(["node_modules"]);
|
|
55
198
|
function isSkippedDirectory(name) {
|
|
56
|
-
return name.startsWith(".") ||
|
|
199
|
+
return name.startsWith(".") || NEVER_LISTED2.has(name);
|
|
57
200
|
}
|
|
58
|
-
function
|
|
201
|
+
function refusePathOutsideRepo2(relativePath, cause) {
|
|
59
202
|
return throwOgygieError(
|
|
60
203
|
{
|
|
61
204
|
code: "PATH_OUTSIDE_REPO",
|
|
@@ -69,12 +212,12 @@ function refusePathOutsideRepo(relativePath, cause) {
|
|
|
69
212
|
}
|
|
70
213
|
function resolveInsideRoot(rootDir, relativePath) {
|
|
71
214
|
if (isAbsolute(relativePath)) {
|
|
72
|
-
|
|
215
|
+
refusePathOutsideRepo2(relativePath, `"${relativePath}" is an absolute path`);
|
|
73
216
|
}
|
|
74
217
|
const root = resolve(rootDir);
|
|
75
218
|
const absolute = resolve(root, relativePath);
|
|
76
219
|
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
|
77
|
-
|
|
220
|
+
refusePathOutsideRepo2(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
|
|
78
221
|
}
|
|
79
222
|
return absolute;
|
|
80
223
|
}
|
|
@@ -88,7 +231,7 @@ async function assertRealPathInsideRoot(rootDir, absolute, relativePath) {
|
|
|
88
231
|
return;
|
|
89
232
|
}
|
|
90
233
|
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
|
|
91
|
-
|
|
234
|
+
refusePathOutsideRepo2(
|
|
92
235
|
relativePath,
|
|
93
236
|
`"${relativePath}" is a symbolic link leading to "${realTarget}", outside "${realRoot}"`
|
|
94
237
|
);
|
|
@@ -204,43 +347,60 @@ function createFilesystemRepoAdapter(rootDir) {
|
|
|
204
347
|
};
|
|
205
348
|
}
|
|
206
349
|
|
|
207
|
-
// src/
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json)."
|
|
219
|
-
},
|
|
220
|
-
400
|
|
221
|
-
);
|
|
350
|
+
// ../ogygie-core/src/proposal.ts
|
|
351
|
+
import { z as z5 } from "zod";
|
|
352
|
+
|
|
353
|
+
// ../ogygie-core/src/decision-refs.ts
|
|
354
|
+
var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
|
|
355
|
+
var DECISION_HEADING = /^##\s+D(\d+)\b/;
|
|
356
|
+
function parseKnownDecisionIds(decisionsMarkdown) {
|
|
357
|
+
const ids = [];
|
|
358
|
+
for (const line of decisionsMarkdown.split("\n")) {
|
|
359
|
+
const heading = DECISION_HEADING.exec(line);
|
|
360
|
+
if (heading !== null) ids.push(`D${heading[1]}`);
|
|
222
361
|
}
|
|
223
|
-
return
|
|
362
|
+
return ids;
|
|
224
363
|
}
|
|
225
|
-
function
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const absolute = resolveInsideRoot2(rootDir, path);
|
|
231
|
-
await mkdir(dirname(absolute), { recursive: true });
|
|
232
|
-
await writeFile(absolute, content, "utf8");
|
|
233
|
-
}
|
|
364
|
+
function extractDecisionSection(decisionsMarkdown, decisionId) {
|
|
365
|
+
const lines = decisionsMarkdown.split("\n");
|
|
366
|
+
const trimTrailingBlanks = (section) => {
|
|
367
|
+
while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
|
|
368
|
+
return section.join("\n");
|
|
234
369
|
};
|
|
370
|
+
let start = -1;
|
|
371
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
372
|
+
const heading = DECISION_HEADING.exec(lines[index] ?? "");
|
|
373
|
+
if (heading === null) continue;
|
|
374
|
+
if (start === -1) {
|
|
375
|
+
if (`D${heading[1]}` === decisionId) start = index;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
return trimTrailingBlanks(lines.slice(start, index));
|
|
379
|
+
}
|
|
380
|
+
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
381
|
+
}
|
|
382
|
+
function findDecisionReferences(text) {
|
|
383
|
+
const references = [];
|
|
384
|
+
const lines = text.split("\n");
|
|
385
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
386
|
+
const line = lines[index] ?? "";
|
|
387
|
+
for (const match of line.matchAll(DECISION_REFERENCE)) {
|
|
388
|
+
references.push({ decision: `D${match[1]}`, line: index + 1 });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return references;
|
|
392
|
+
}
|
|
393
|
+
function findUnknownDecisionReferences(text, knownDecisionIds) {
|
|
394
|
+
const known = new Set(knownDecisionIds);
|
|
395
|
+
const unknown = [];
|
|
396
|
+
const alreadyReported = /* @__PURE__ */ new Set();
|
|
397
|
+
for (const reference of findDecisionReferences(text)) {
|
|
398
|
+
if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
|
|
399
|
+
alreadyReported.add(reference.decision);
|
|
400
|
+
unknown.push(reference);
|
|
401
|
+
}
|
|
402
|
+
return unknown;
|
|
235
403
|
}
|
|
236
|
-
|
|
237
|
-
// src/server/app.ts
|
|
238
|
-
import Fastify from "fastify";
|
|
239
|
-
import {
|
|
240
|
-
hasZodFastifySchemaValidationErrors,
|
|
241
|
-
serializerCompiler,
|
|
242
|
-
validatorCompiler
|
|
243
|
-
} from "fastify-type-provider-zod";
|
|
244
404
|
|
|
245
405
|
// ../ogygie-core/src/spec.schema.ts
|
|
246
406
|
import { z as z4 } from "zod";
|
|
@@ -631,131 +791,556 @@ function parseSpecFrontmatter(text) {
|
|
|
631
791
|
}
|
|
632
792
|
return { value, problems };
|
|
633
793
|
}
|
|
634
|
-
|
|
635
|
-
// src/server/service.ts
|
|
636
|
-
var SPECS_DIR = "specs";
|
|
637
|
-
var TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
|
|
638
|
-
var MANIFEST_PATH = "ogygie.manifest.json";
|
|
639
794
|
var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
|
|
640
|
-
function
|
|
795
|
+
function parseSpecDocument(content) {
|
|
641
796
|
const match = FRONTMATTER_BLOCK.exec(content);
|
|
642
|
-
if (match === null || match[1] === void 0)
|
|
643
|
-
return { frontmatterText: match[1], body: content.slice(match[0].length) };
|
|
644
|
-
}
|
|
645
|
-
function presentSectionsOf(body) {
|
|
646
|
-
const searchable = `
|
|
647
|
-
${body}`;
|
|
648
|
-
const present = [];
|
|
649
|
-
let cursor = 0;
|
|
650
|
-
for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
|
|
651
|
-
const position = searchable.indexOf(`
|
|
652
|
-
${section}`, cursor);
|
|
653
|
-
if (position === -1) continue;
|
|
654
|
-
present.push(section);
|
|
655
|
-
cursor = position + section.length;
|
|
656
|
-
}
|
|
657
|
-
return present;
|
|
658
|
-
}
|
|
659
|
-
function missingSectionsOf(body) {
|
|
660
|
-
const present = new Set(presentSectionsOf(body));
|
|
661
|
-
return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
|
|
662
|
-
}
|
|
663
|
-
function quoteFrontmatterElement(value, specPath) {
|
|
664
|
-
if (value.includes("\n") || value.includes('"') && value.includes("'")) {
|
|
665
|
-
throwOgygieError(
|
|
666
|
-
{
|
|
667
|
-
code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
|
|
668
|
-
message: "A frontmatter array element cannot be written in the restricted spec format.",
|
|
669
|
-
file: specPath,
|
|
670
|
-
cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
|
|
671
|
-
suggestedFix: "Rewrite the element without newlines and with at most one kind of quote character."
|
|
672
|
-
},
|
|
673
|
-
422
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
return value.includes('"') ? `'${value}'` : `"${value}"`;
|
|
677
|
-
}
|
|
678
|
-
function serializeSpecFrontmatter(frontmatter, specPath) {
|
|
679
|
-
const quote = (value) => quoteFrontmatterElement(value, specPath);
|
|
680
|
-
const lines = [
|
|
681
|
-
`id: ${frontmatter.id}`,
|
|
682
|
-
`status: ${frontmatter.status}`,
|
|
683
|
-
`depends_on: [${frontmatter.depends_on.join(", ")}]`,
|
|
684
|
-
`priority: ${frontmatter.priority}`,
|
|
685
|
-
`refs: [${frontmatter.refs.map(quote).join(", ")}]`
|
|
686
|
-
];
|
|
687
|
-
if (frontmatter.deferred.length > 0) {
|
|
688
|
-
lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
|
|
689
|
-
}
|
|
690
|
-
return lines.join("\n");
|
|
691
|
-
}
|
|
692
|
-
function specPathOf(specId) {
|
|
693
|
-
return `${SPECS_DIR}/${specId}.spec.md`;
|
|
694
|
-
}
|
|
695
|
-
function frontmatterInvalidError(filePath, cause) {
|
|
696
|
-
return ogygieError({
|
|
697
|
-
code: "SPEC_FRONTMATTER_INVALID",
|
|
698
|
-
message: `Spec ${filePath} has an invalid frontmatter.`,
|
|
699
|
-
file: filePath,
|
|
700
|
-
cause,
|
|
701
|
-
suggestedFix: "Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts \u2014 the studio never repairs a spec silently."
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
function parseSpec(filePath, fileName, content) {
|
|
705
|
-
const split = splitSpecFile(content);
|
|
706
|
-
if (split === null) {
|
|
797
|
+
if (match === null || match[1] === void 0) {
|
|
707
798
|
return {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
"file does not start with a --- ... --- frontmatter block"
|
|
712
|
-
)
|
|
799
|
+
frontmatter: null,
|
|
800
|
+
problems: ["file does not start with a --- ... --- frontmatter block"],
|
|
801
|
+
body: content
|
|
713
802
|
};
|
|
714
803
|
}
|
|
715
|
-
const
|
|
804
|
+
const body = content.slice(match[0].length);
|
|
805
|
+
const parsed = parseSpecFrontmatter(match[1]);
|
|
716
806
|
if (parsed.problems.length > 0) {
|
|
717
|
-
return {
|
|
807
|
+
return { frontmatter: null, problems: parsed.problems, body };
|
|
718
808
|
}
|
|
719
809
|
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
720
810
|
if (!checked.success) {
|
|
721
811
|
return {
|
|
722
|
-
|
|
723
|
-
error:
|
|
724
|
-
|
|
725
|
-
checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")
|
|
726
|
-
)
|
|
812
|
+
frontmatter: null,
|
|
813
|
+
problems: checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
|
814
|
+
body
|
|
727
815
|
};
|
|
728
816
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
817
|
+
return { frontmatter: checked.data, problems: [], body };
|
|
818
|
+
}
|
|
819
|
+
var DESIGN_SECTION_TITLE = "Design";
|
|
820
|
+
var COMPOSITION_LINE = /^\s*(?:[-*]\s*)?[Cc]omposition\s*:\s*`?([a-z][a-z0-9]*(?:-[a-z0-9]+)*)`?(?![A-Za-z0-9])/mu;
|
|
821
|
+
function compositionOfSpec(content) {
|
|
822
|
+
const heading = new RegExp(`^# ${DESIGN_SECTION_TITLE}\\s*$`, "mu").exec(content);
|
|
823
|
+
if (heading === null) return null;
|
|
824
|
+
const rest = content.slice(heading.index + heading[0].length);
|
|
825
|
+
const next = /^# /mu.exec(rest);
|
|
826
|
+
const section = next === null ? rest : rest.slice(0, next.index);
|
|
827
|
+
return COMPOSITION_LINE.exec(section)?.[1] ?? null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ../ogygie-core/src/proposal.ts
|
|
831
|
+
var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
|
|
832
|
+
var GithubCoordinatesSchema = z5.object({
|
|
833
|
+
owner: z5.string().min(1),
|
|
834
|
+
repo: z5.string().min(1),
|
|
835
|
+
defaultBranch: z5.string().min(1).default("main")
|
|
836
|
+
});
|
|
837
|
+
var ProposablePathSchema = z5.union([
|
|
838
|
+
z5.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
|
|
839
|
+
z5.literal("DECISIONS.md"),
|
|
840
|
+
z5.literal("DECISIONS.product.md")
|
|
841
|
+
]);
|
|
842
|
+
var ProposalSchema = z5.object({
|
|
843
|
+
repo: z5.string(),
|
|
844
|
+
path: ProposablePathSchema,
|
|
845
|
+
branch: z5.string(),
|
|
846
|
+
baseSha: z5.string(),
|
|
847
|
+
commitSha: z5.string(),
|
|
848
|
+
pullRequestNumber: z5.number().int().positive(),
|
|
849
|
+
pullRequestUrl: z5.url(),
|
|
850
|
+
/** false when an open pull request was updated instead of created. */
|
|
851
|
+
created: z5.boolean()
|
|
852
|
+
});
|
|
853
|
+
function assertProposablePath(path) {
|
|
854
|
+
const parsed = ProposablePathSchema.safeParse(path);
|
|
855
|
+
if (!parsed.success) {
|
|
856
|
+
return throwOgygieError({
|
|
857
|
+
code: "WRITE_PATH_FORBIDDEN",
|
|
858
|
+
message: `"${path}" is outside what a proposal may touch.`,
|
|
859
|
+
file: path,
|
|
860
|
+
cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
|
|
861
|
+
suggestedFix: "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17)."
|
|
862
|
+
});
|
|
741
863
|
}
|
|
742
|
-
return
|
|
864
|
+
return parsed.data;
|
|
743
865
|
}
|
|
744
|
-
function
|
|
866
|
+
function createGithubWriteClient(deps, github) {
|
|
867
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
868
|
+
const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
|
|
869
|
+
const credential = deps.credentialHint ?? "the GitHub token";
|
|
870
|
+
async function request(method, url, body, allowNotFound) {
|
|
871
|
+
let response;
|
|
872
|
+
try {
|
|
873
|
+
response = await deps.fetchImpl(url, {
|
|
874
|
+
method,
|
|
875
|
+
headers: {
|
|
876
|
+
authorization: `Bearer ${deps.token}`,
|
|
877
|
+
accept: "application/vnd.github+json",
|
|
878
|
+
"x-github-api-version": "2022-11-28",
|
|
879
|
+
"user-agent": deps.userAgent ?? "ogygie-core",
|
|
880
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
881
|
+
},
|
|
882
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
883
|
+
});
|
|
884
|
+
} catch (thrown) {
|
|
885
|
+
return throwOgygieError({
|
|
886
|
+
code: "GITHUB_UNAVAILABLE",
|
|
887
|
+
message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
|
|
888
|
+
file: null,
|
|
889
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
890
|
+
suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
if (response.status === 404 && allowNotFound) return null;
|
|
894
|
+
if (!response.ok) {
|
|
895
|
+
return throwOgygieError({
|
|
896
|
+
code: "GITHUB_UNAVAILABLE",
|
|
897
|
+
message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
|
|
898
|
+
file: null,
|
|
899
|
+
cause: `status=${response.status}`,
|
|
900
|
+
suggestedFix: response.status === 401 || response.status === 403 ? `Check ${credential}: it must grant contents and pull requests on this repo.` : "Retry later \u2014 nothing was written, nothing is queued."
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return await response.json();
|
|
904
|
+
}
|
|
745
905
|
return {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
906
|
+
async getBranchHead(branch) {
|
|
907
|
+
const ref = await request(
|
|
908
|
+
"GET",
|
|
909
|
+
`${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
|
|
910
|
+
void 0,
|
|
911
|
+
true
|
|
912
|
+
);
|
|
913
|
+
return ref?.object.sha ?? null;
|
|
914
|
+
},
|
|
915
|
+
async readFileAtRef(path, ref) {
|
|
916
|
+
const file = await request(
|
|
917
|
+
"GET",
|
|
918
|
+
`${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
|
|
919
|
+
void 0,
|
|
920
|
+
true
|
|
921
|
+
);
|
|
922
|
+
if (file === null) return null;
|
|
923
|
+
return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
|
|
924
|
+
},
|
|
925
|
+
async createBranch(branch, fromSha) {
|
|
926
|
+
await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
|
|
927
|
+
},
|
|
928
|
+
async commitFile(input) {
|
|
929
|
+
const result = await request(
|
|
930
|
+
"PUT",
|
|
931
|
+
`${repoUrl}/contents/${input.path}`,
|
|
932
|
+
{
|
|
933
|
+
message: input.message,
|
|
934
|
+
content: Buffer.from(input.content, "utf8").toString("base64"),
|
|
935
|
+
branch: input.branch,
|
|
936
|
+
...input.expectedSha === null ? {} : { sha: input.expectedSha },
|
|
937
|
+
...input.author === void 0 ? {} : { author: input.author }
|
|
938
|
+
},
|
|
939
|
+
false
|
|
940
|
+
);
|
|
941
|
+
return { commitSha: result?.commit.sha ?? "" };
|
|
942
|
+
},
|
|
943
|
+
async findOpenPullRequest(branch) {
|
|
944
|
+
const pulls = await request(
|
|
945
|
+
"GET",
|
|
946
|
+
`${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
|
|
947
|
+
void 0,
|
|
948
|
+
false
|
|
949
|
+
);
|
|
950
|
+
const first = pulls?.[0];
|
|
951
|
+
return first === void 0 ? null : { number: first.number, url: first.html_url };
|
|
952
|
+
},
|
|
953
|
+
async openPullRequest(input) {
|
|
954
|
+
const pull = await request(
|
|
955
|
+
"POST",
|
|
956
|
+
`${repoUrl}/pulls`,
|
|
957
|
+
{ title: input.title, body: input.body, head: input.branch, base: input.base },
|
|
958
|
+
false
|
|
959
|
+
);
|
|
960
|
+
return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
|
|
961
|
+
}
|
|
752
962
|
};
|
|
753
963
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
964
|
+
var HOUR_MS = 36e5;
|
|
965
|
+
var DECISION_HEADING2 = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
|
|
966
|
+
var DECISION_BULLETS = ["**D\xE9cision**", "**Motivation**", "**Exclut**"];
|
|
967
|
+
function specIdOfPath(path) {
|
|
968
|
+
return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
|
|
757
969
|
}
|
|
758
|
-
|
|
970
|
+
function assertSpecProposable(path, content) {
|
|
971
|
+
const parsed = parseSpecDocument(content);
|
|
972
|
+
const problems = [...parsed.problems];
|
|
973
|
+
if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
|
|
974
|
+
problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
|
|
975
|
+
}
|
|
976
|
+
let cursor = 0;
|
|
977
|
+
for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
|
|
978
|
+
const position = parsed.body.indexOf(`
|
|
979
|
+
${section}
|
|
980
|
+
`, cursor);
|
|
981
|
+
if (position === -1) problems.push(`section "${section}" missing or out of order`);
|
|
982
|
+
else cursor = position + section.length;
|
|
983
|
+
}
|
|
984
|
+
if (problems.length > 0) {
|
|
985
|
+
throwOgygieError({
|
|
986
|
+
code: "SPEC_INVALID",
|
|
987
|
+
message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
|
|
988
|
+
file: path,
|
|
989
|
+
cause: problems.join(" ; "),
|
|
990
|
+
suggestedFix: "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order."
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
if (parsed.frontmatter?.status === "implemented") {
|
|
994
|
+
throwOgygieError({
|
|
995
|
+
code: "SPEC_STATUS_FORBIDDEN",
|
|
996
|
+
message: "A proposal may not declare a spec implemented.",
|
|
997
|
+
file: path,
|
|
998
|
+
cause: "status=implemented",
|
|
999
|
+
suggestedFix: "Propose the spec as draft or validated \u2014 the repo's manifest and CI say when it is implemented."
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function assertDecisionsProposable(path, content, baseSame, baseOther) {
|
|
1004
|
+
const problems = [];
|
|
1005
|
+
for (const line of content.split("\n")) {
|
|
1006
|
+
if (line.startsWith("## ") && !DECISION_HEADING2.test(line)) {
|
|
1007
|
+
problems.push(`heading "${line}" is not "## D<n> \u2014 AAAA-MM-JJ \u2014 <titre>"`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const proposedIds = parseKnownDecisionIds(content);
|
|
1011
|
+
const otherIds = parseKnownDecisionIds(baseOther ?? "");
|
|
1012
|
+
const previousIds = parseKnownDecisionIds(baseSame ?? "");
|
|
1013
|
+
for (const id of previousIds) {
|
|
1014
|
+
if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
|
|
1015
|
+
}
|
|
1016
|
+
const all = [...proposedIds, ...otherIds];
|
|
1017
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1018
|
+
for (const id of all) {
|
|
1019
|
+
if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
|
|
1020
|
+
seen.add(id);
|
|
1021
|
+
}
|
|
1022
|
+
const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
|
|
1023
|
+
numbers.forEach((value, index) => {
|
|
1024
|
+
if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
|
|
1025
|
+
});
|
|
1026
|
+
for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
|
|
1027
|
+
const section = extractDecisionSection(content, id) ?? "";
|
|
1028
|
+
for (const bullet of DECISION_BULLETS) {
|
|
1029
|
+
if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (problems.length > 0) {
|
|
1033
|
+
throwOgygieError({
|
|
1034
|
+
code: "DECISION_INVALID",
|
|
1035
|
+
message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
|
|
1036
|
+
file: path,
|
|
1037
|
+
cause: problems.join(" ; "),
|
|
1038
|
+
suggestedFix: "Keep every existing decision, number the new one right after the last across both files, and give it its D\xE9cision / Motivation / Exclut bullets."
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function assertDecisionReferencesKnown(path, content, known) {
|
|
1043
|
+
const unknown = findUnknownDecisionReferences(content, known);
|
|
1044
|
+
if (unknown.length > 0) {
|
|
1045
|
+
throwOgygieError({
|
|
1046
|
+
code: "UNKNOWN_DECISION_REFERENCE",
|
|
1047
|
+
message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
|
|
1048
|
+
file: path,
|
|
1049
|
+
cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
|
|
1050
|
+
suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function branchSlug(path) {
|
|
1055
|
+
return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
1056
|
+
}
|
|
1057
|
+
function defaultBranchFor(target, path) {
|
|
1058
|
+
return `mcp/${target.id}/${branchSlug(path)}`;
|
|
1059
|
+
}
|
|
1060
|
+
async function withGithub(action) {
|
|
1061
|
+
try {
|
|
1062
|
+
return await action();
|
|
1063
|
+
} catch (thrown) {
|
|
1064
|
+
if (thrown instanceof OgygieFailure) throw thrown;
|
|
1065
|
+
return throwOgygieError({
|
|
1066
|
+
code: "GITHUB_UNAVAILABLE",
|
|
1067
|
+
message: "The GitHub client failed outside its contract.",
|
|
1068
|
+
file: null,
|
|
1069
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
1070
|
+
suggestedFix: "Retry \u2014 nothing was written, nothing is queued."
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
async function proposeChange(target, deps, input) {
|
|
1075
|
+
const path = assertProposablePath(input.path);
|
|
1076
|
+
const github = target.github;
|
|
1077
|
+
const now = deps.now();
|
|
1078
|
+
const recent = (deps.recentProposals.get(target.id) ?? []).filter((at) => now - at < HOUR_MS);
|
|
1079
|
+
if (recent.length >= deps.rateLimitPerHour) {
|
|
1080
|
+
const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1e3);
|
|
1081
|
+
throwOgygieError({
|
|
1082
|
+
code: "WRITE_RATE_LIMITED",
|
|
1083
|
+
message: `Repo "${target.id}" already received ${recent.length} proposals this hour.`,
|
|
1084
|
+
file: null,
|
|
1085
|
+
cause: `limit=${deps.rateLimitPerHour}/hour`,
|
|
1086
|
+
suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const client = deps.clientFor(github);
|
|
1090
|
+
const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
|
|
1091
|
+
if (baseSha === null) {
|
|
1092
|
+
return throwOgygieError({
|
|
1093
|
+
code: "GITHUB_UNAVAILABLE",
|
|
1094
|
+
message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
|
|
1095
|
+
file: null,
|
|
1096
|
+
cause: "default branch head not found",
|
|
1097
|
+
suggestedFix: "Fix `defaultBranch` in the GitHub coordinates of that repo."
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const baseFiles = /* @__PURE__ */ new Map();
|
|
1101
|
+
for (const decisionsPath of DECISION_FILES) {
|
|
1102
|
+
const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
|
|
1103
|
+
baseFiles.set(decisionsPath, file?.content ?? null);
|
|
1104
|
+
}
|
|
1105
|
+
if (path.startsWith("specs/")) {
|
|
1106
|
+
assertSpecProposable(path, input.content);
|
|
1107
|
+
const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
|
|
1108
|
+
assertDecisionReferencesKnown(path, input.content, known);
|
|
1109
|
+
} else {
|
|
1110
|
+
const other = DECISION_FILES.find((file) => file !== path) ?? DECISION_FILES[1];
|
|
1111
|
+
assertDecisionsProposable(path, input.content, baseFiles.get(path) ?? null, baseFiles.get(other) ?? null);
|
|
1112
|
+
const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
|
|
1113
|
+
assertDecisionReferencesKnown(path, input.content, known);
|
|
1114
|
+
}
|
|
1115
|
+
let branch = (deps.branchFor ?? defaultBranchFor)(target, path);
|
|
1116
|
+
let branchHead = await withGithub(() => client.getBranchHead(branch));
|
|
1117
|
+
let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
|
|
1118
|
+
if (branchHead !== null && openPull === null) {
|
|
1119
|
+
branch = `${branch}-${baseSha.slice(0, 7)}`;
|
|
1120
|
+
branchHead = await withGithub(() => client.getBranchHead(branch));
|
|
1121
|
+
openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
|
|
1122
|
+
}
|
|
1123
|
+
const headOfBranch = branchHead;
|
|
1124
|
+
const baseFile = await withGithub(() => client.readFileAtRef(path, baseSha));
|
|
1125
|
+
const branchFile = headOfBranch === null ? null : await withGithub(() => client.readFileAtRef(path, headOfBranch));
|
|
1126
|
+
const readsBranch = deps.readFrom === "branch" && headOfBranch !== null;
|
|
1127
|
+
const compared = readsBranch ? branchFile : baseFile;
|
|
1128
|
+
const comparedName = readsBranch ? branch : github.defaultBranch;
|
|
1129
|
+
const local = await target.adapter.exists(path) ? await target.adapter.readFile(path) : null;
|
|
1130
|
+
if ((compared?.content ?? null) !== local) {
|
|
1131
|
+
throwOgygieError({
|
|
1132
|
+
code: "REMOTE_CONFLICT",
|
|
1133
|
+
message: `"${path}" changed on ${comparedName} since it was read.`,
|
|
1134
|
+
file: path,
|
|
1135
|
+
cause: `head sha=${compared?.sha ?? "<absent>"}, the read copy ${local === null ? "has no such file" : "differs"}`,
|
|
1136
|
+
suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
|
|
1137
|
+
details: { headSha: compared?.sha ?? null, headContent: compared?.content ?? null }
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
recent.push(now);
|
|
1141
|
+
deps.recentProposals.set(target.id, recent);
|
|
1142
|
+
const expectedSha = headOfBranch === null ? baseFile?.sha ?? null : branchFile?.sha ?? null;
|
|
1143
|
+
if (headOfBranch === null) {
|
|
1144
|
+
await withGithub(() => client.createBranch(branch, baseSha));
|
|
1145
|
+
}
|
|
1146
|
+
const author = deps.authorOf?.();
|
|
1147
|
+
const commit = await withGithub(
|
|
1148
|
+
() => client.commitFile({
|
|
1149
|
+
branch,
|
|
1150
|
+
path,
|
|
1151
|
+
content: input.content,
|
|
1152
|
+
message: input.message,
|
|
1153
|
+
expectedSha,
|
|
1154
|
+
...author === void 0 ? {} : { author }
|
|
1155
|
+
})
|
|
1156
|
+
);
|
|
1157
|
+
const pull = openPull ?? await withGithub(
|
|
1158
|
+
() => client.openPullRequest({
|
|
1159
|
+
branch,
|
|
1160
|
+
base: github.defaultBranch,
|
|
1161
|
+
title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
|
|
1162
|
+
body: `${input.message}
|
|
1163
|
+
|
|
1164
|
+
---
|
|
1165
|
+
${deps.originOf(path)}`
|
|
1166
|
+
})
|
|
1167
|
+
);
|
|
1168
|
+
return {
|
|
1169
|
+
repo: target.id,
|
|
1170
|
+
path,
|
|
1171
|
+
branch,
|
|
1172
|
+
baseSha,
|
|
1173
|
+
commitSha: commit.commitSha,
|
|
1174
|
+
pullRequestNumber: pull.number,
|
|
1175
|
+
pullRequestUrl: pull.url,
|
|
1176
|
+
created: openPull === null
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/server/storage/local-adapter.ts
|
|
1181
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
1182
|
+
import { dirname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
1183
|
+
function resolveInsideRoot2(rootDir, relativePath) {
|
|
1184
|
+
const root = resolve2(rootDir);
|
|
1185
|
+
const absolute = resolve2(root, relativePath);
|
|
1186
|
+
if (isAbsolute2(relativePath) || absolute !== root && !absolute.startsWith(root + sep2)) {
|
|
1187
|
+
throwOgygieError(
|
|
1188
|
+
{
|
|
1189
|
+
code: "PATH_OUTSIDE_REPO",
|
|
1190
|
+
message: "The requested path escapes the repo root.",
|
|
1191
|
+
file: relativePath,
|
|
1192
|
+
cause: `relative path "${relativePath}" resolves to "${absolute}", outside "${root}"`,
|
|
1193
|
+
suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json)."
|
|
1194
|
+
},
|
|
1195
|
+
400
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
return absolute;
|
|
1199
|
+
}
|
|
1200
|
+
function createLocalSpecStorage(rootDir) {
|
|
1201
|
+
const reader = createFilesystemRepoAdapter(rootDir);
|
|
1202
|
+
return {
|
|
1203
|
+
...reader,
|
|
1204
|
+
async write(path, content, _message) {
|
|
1205
|
+
const absolute = resolveInsideRoot2(rootDir, path);
|
|
1206
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
1207
|
+
await writeFile(absolute, content, "utf8");
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/server/app.ts
|
|
1213
|
+
import Fastify from "fastify";
|
|
1214
|
+
import {
|
|
1215
|
+
hasZodFastifySchemaValidationErrors,
|
|
1216
|
+
serializerCompiler,
|
|
1217
|
+
validatorCompiler
|
|
1218
|
+
} from "fastify-type-provider-zod";
|
|
1219
|
+
|
|
1220
|
+
// src/server/service.ts
|
|
1221
|
+
var SPECS_DIR = "specs";
|
|
1222
|
+
var TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
|
|
1223
|
+
var MANIFEST_PATH = "ogygie.manifest.json";
|
|
1224
|
+
var FRONTMATTER_BLOCK2 = /^---\n([\s\S]*?)\n---/;
|
|
1225
|
+
function splitSpecFile(content) {
|
|
1226
|
+
const match = FRONTMATTER_BLOCK2.exec(content);
|
|
1227
|
+
if (match === null || match[1] === void 0) return null;
|
|
1228
|
+
return { frontmatterText: match[1], body: content.slice(match[0].length) };
|
|
1229
|
+
}
|
|
1230
|
+
function presentSectionsOf(body) {
|
|
1231
|
+
const searchable = `
|
|
1232
|
+
${body}`;
|
|
1233
|
+
const present = [];
|
|
1234
|
+
let cursor = 0;
|
|
1235
|
+
for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
|
|
1236
|
+
const position = searchable.indexOf(`
|
|
1237
|
+
${section}`, cursor);
|
|
1238
|
+
if (position === -1) continue;
|
|
1239
|
+
present.push(section);
|
|
1240
|
+
cursor = position + section.length;
|
|
1241
|
+
}
|
|
1242
|
+
return present;
|
|
1243
|
+
}
|
|
1244
|
+
function missingSectionsOf(body) {
|
|
1245
|
+
const present = new Set(presentSectionsOf(body));
|
|
1246
|
+
return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
|
|
1247
|
+
}
|
|
1248
|
+
function quoteFrontmatterElement(value, specPath) {
|
|
1249
|
+
if (value.includes("\n") || value.includes('"') && value.includes("'")) {
|
|
1250
|
+
throwOgygieError(
|
|
1251
|
+
{
|
|
1252
|
+
code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
|
|
1253
|
+
message: "A frontmatter array element cannot be written in the restricted spec format.",
|
|
1254
|
+
file: specPath,
|
|
1255
|
+
cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
|
|
1256
|
+
suggestedFix: "Rewrite the element without newlines and with at most one kind of quote character."
|
|
1257
|
+
},
|
|
1258
|
+
422
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
return value.includes('"') ? `'${value}'` : `"${value}"`;
|
|
1262
|
+
}
|
|
1263
|
+
function serializeSpecFrontmatter(frontmatter, specPath) {
|
|
1264
|
+
const quote = (value) => quoteFrontmatterElement(value, specPath);
|
|
1265
|
+
const lines = [
|
|
1266
|
+
`id: ${frontmatter.id}`,
|
|
1267
|
+
`status: ${frontmatter.status}`,
|
|
1268
|
+
`depends_on: [${frontmatter.depends_on.join(", ")}]`,
|
|
1269
|
+
`priority: ${frontmatter.priority}`,
|
|
1270
|
+
`refs: [${frontmatter.refs.map(quote).join(", ")}]`
|
|
1271
|
+
];
|
|
1272
|
+
if (frontmatter.deferred.length > 0) {
|
|
1273
|
+
lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
|
|
1274
|
+
}
|
|
1275
|
+
return lines.join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
function specPathOf(specId) {
|
|
1278
|
+
return `${SPECS_DIR}/${specId}.spec.md`;
|
|
1279
|
+
}
|
|
1280
|
+
function frontmatterInvalidError(filePath, cause) {
|
|
1281
|
+
return ogygieError({
|
|
1282
|
+
code: "SPEC_FRONTMATTER_INVALID",
|
|
1283
|
+
message: `Spec ${filePath} has an invalid frontmatter.`,
|
|
1284
|
+
file: filePath,
|
|
1285
|
+
cause,
|
|
1286
|
+
suggestedFix: "Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts \u2014 the studio never repairs a spec silently."
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
function parseSpec(filePath, fileName, content) {
|
|
1290
|
+
const split = splitSpecFile(content);
|
|
1291
|
+
if (split === null) {
|
|
1292
|
+
return {
|
|
1293
|
+
ok: false,
|
|
1294
|
+
error: frontmatterInvalidError(
|
|
1295
|
+
filePath,
|
|
1296
|
+
"file does not start with a --- ... --- frontmatter block"
|
|
1297
|
+
)
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const parsed = parseSpecFrontmatter(split.frontmatterText);
|
|
1301
|
+
if (parsed.problems.length > 0) {
|
|
1302
|
+
return { ok: false, error: frontmatterInvalidError(filePath, parsed.problems.join(" ; ")) };
|
|
1303
|
+
}
|
|
1304
|
+
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
1305
|
+
if (!checked.success) {
|
|
1306
|
+
return {
|
|
1307
|
+
ok: false,
|
|
1308
|
+
error: frontmatterInvalidError(
|
|
1309
|
+
filePath,
|
|
1310
|
+
checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")
|
|
1311
|
+
)
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const expectedId = fileName.replace(/\.spec\.md$/, "");
|
|
1315
|
+
if (checked.data.id !== expectedId) {
|
|
1316
|
+
return {
|
|
1317
|
+
ok: false,
|
|
1318
|
+
error: ogygieError({
|
|
1319
|
+
code: "SPEC_ID_MISMATCH",
|
|
1320
|
+
message: `Spec ${fileName} declares id "${checked.data.id}" \u2014 expected "${expectedId}".`,
|
|
1321
|
+
file: filePath,
|
|
1322
|
+
cause: `id=${checked.data.id} \u2260 filename ${expectedId}`,
|
|
1323
|
+
suggestedFix: "Make the frontmatter id identical to the file name."
|
|
1324
|
+
})
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
|
|
1328
|
+
}
|
|
1329
|
+
function toReadableSummary(filePath, spec) {
|
|
1330
|
+
return {
|
|
1331
|
+
id: spec.frontmatter.id,
|
|
1332
|
+
status: spec.frontmatter.status,
|
|
1333
|
+
priority: spec.frontmatter.priority,
|
|
1334
|
+
depends_on: spec.frontmatter.depends_on,
|
|
1335
|
+
filePath,
|
|
1336
|
+
presentSections: presentSectionsOf(spec.body)
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
async function listSpecFileNames(deps) {
|
|
1340
|
+
const entries = await deps.storage.listDirectory(SPECS_DIR);
|
|
1341
|
+
return entries.map((entry) => entry.path).filter((path) => !path.includes("/")).filter((name) => name.endsWith(".spec.md") && !name.startsWith("_")).sort();
|
|
1342
|
+
}
|
|
1343
|
+
async function listSpecSummaries(deps) {
|
|
759
1344
|
const fileNames = await listSpecFileNames(deps);
|
|
760
1345
|
const summaries = [];
|
|
761
1346
|
for (const fileName of fileNames) {
|
|
@@ -947,64 +1532,180 @@ async function getManifest(deps) {
|
|
|
947
1532
|
}
|
|
948
1533
|
|
|
949
1534
|
// src/server/schemas.ts
|
|
950
|
-
import { z as
|
|
1535
|
+
import { z as z6 } from "zod";
|
|
951
1536
|
var SpecStatusSchema = SpecFrontmatterSchema.shape.status;
|
|
952
|
-
var ReadableSpecSummarySchema =
|
|
1537
|
+
var ReadableSpecSummarySchema = z6.object({
|
|
953
1538
|
id: FeatureNameSchema,
|
|
954
1539
|
status: SpecStatusSchema,
|
|
955
1540
|
priority: SpecFrontmatterSchema.shape.priority,
|
|
956
|
-
depends_on:
|
|
957
|
-
filePath:
|
|
958
|
-
presentSections:
|
|
1541
|
+
depends_on: z6.array(FeatureNameSchema),
|
|
1542
|
+
filePath: z6.string(),
|
|
1543
|
+
presentSections: z6.array(z6.string())
|
|
959
1544
|
});
|
|
960
|
-
var UnreadableSpecSummarySchema =
|
|
961
|
-
id:
|
|
962
|
-
filePath:
|
|
1545
|
+
var UnreadableSpecSummarySchema = z6.object({
|
|
1546
|
+
id: z6.string(),
|
|
1547
|
+
filePath: z6.string(),
|
|
963
1548
|
error: OgygieErrorSchema
|
|
964
1549
|
});
|
|
965
|
-
var SpecSummarySchema =
|
|
1550
|
+
var SpecSummarySchema = z6.union([
|
|
966
1551
|
ReadableSpecSummarySchema,
|
|
967
1552
|
UnreadableSpecSummarySchema
|
|
968
1553
|
]);
|
|
969
|
-
var ListSpecsOutputSchema =
|
|
970
|
-
specs:
|
|
1554
|
+
var ListSpecsOutputSchema = z6.object({
|
|
1555
|
+
specs: z6.array(SpecSummarySchema)
|
|
971
1556
|
});
|
|
972
|
-
var SpecParamsSchema =
|
|
1557
|
+
var SpecParamsSchema = z6.object({
|
|
973
1558
|
specId: FeatureNameSchema
|
|
974
1559
|
});
|
|
975
|
-
var GetSpecOutputSchema =
|
|
1560
|
+
var GetSpecOutputSchema = z6.object({
|
|
976
1561
|
frontmatter: SpecFrontmatterSchema,
|
|
977
1562
|
/** Markdown body after the frontmatter block, byte-exact. */
|
|
978
|
-
body:
|
|
979
|
-
presentSections:
|
|
1563
|
+
body: z6.string(),
|
|
1564
|
+
presentSections: z6.array(z6.string())
|
|
980
1565
|
});
|
|
981
|
-
var UpdateSpecInputSchema =
|
|
1566
|
+
var UpdateSpecInputSchema = z6.object({
|
|
982
1567
|
frontmatter: SpecFrontmatterSchema,
|
|
983
1568
|
/** Omitted ⇒ the existing markdown body is preserved byte-exact. */
|
|
984
|
-
body:
|
|
1569
|
+
body: z6.string().max(2e5).optional()
|
|
985
1570
|
});
|
|
986
|
-
var UpdateSpecOutputSchema =
|
|
1571
|
+
var UpdateSpecOutputSchema = z6.object({
|
|
987
1572
|
spec: ReadableSpecSummarySchema
|
|
988
1573
|
});
|
|
989
|
-
var CreateSpecInputSchema =
|
|
1574
|
+
var CreateSpecInputSchema = z6.object({
|
|
990
1575
|
id: FeatureNameSchema.max(80)
|
|
991
1576
|
});
|
|
992
|
-
var CreateSpecOutputSchema =
|
|
1577
|
+
var CreateSpecOutputSchema = z6.object({
|
|
993
1578
|
spec: ReadableSpecSummarySchema
|
|
994
1579
|
});
|
|
995
|
-
var GetManifestOutputSchema =
|
|
1580
|
+
var GetManifestOutputSchema = z6.object({
|
|
996
1581
|
manifest: OgygieManifestSchema
|
|
997
1582
|
});
|
|
1583
|
+
var BriefInputSchema = z6.object({
|
|
1584
|
+
brief: z6.string().min(50).max(2e4),
|
|
1585
|
+
/** Absent: the model names the new spec. Present and existing: an amendment. */
|
|
1586
|
+
specId: FeatureNameSchema.optional()
|
|
1587
|
+
});
|
|
1588
|
+
var BriefOutputSchema = z6.object({
|
|
1589
|
+
specId: FeatureNameSchema,
|
|
1590
|
+
attempts: z6.number().int().min(1).max(2),
|
|
1591
|
+
problemsFixed: z6.array(z6.string()),
|
|
1592
|
+
/** No feature of the manifest carries this id: its first schemas.ts awaits the operator (D52, lot C). */
|
|
1593
|
+
isNewFeature: z6.boolean(),
|
|
1594
|
+
isAmendment: z6.boolean(),
|
|
1595
|
+
/** D53: the compositions the model proposed for a screen, the retained one (the head's) first; empty when the rubric is not a screen. */
|
|
1596
|
+
compositions: z6.array(z6.string()).default([]),
|
|
1597
|
+
/** The generator's tally on the draft's `# Génération` block (lines bound without a model, lines carried by a hole); null without a block. */
|
|
1598
|
+
generation: z6.object({ derivable: z6.number().int().nonnegative(), hole: z6.number().int().nonnegative() }).nullable().default(null),
|
|
1599
|
+
usage: z6.array(
|
|
1600
|
+
z6.object({ model: z6.string(), inputTokens: z6.number().int().nonnegative(), outputTokens: z6.number().int().nonnegative() })
|
|
1601
|
+
)
|
|
1602
|
+
});
|
|
1603
|
+
var ChecksStateSchema = z6.enum(["green", "red", "pending", "none"]);
|
|
1604
|
+
var PullRequestStatusSchema = z6.object({
|
|
1605
|
+
specId: FeatureNameSchema,
|
|
1606
|
+
branch: z6.string(),
|
|
1607
|
+
pullRequest: z6.object({ number: z6.number().int().positive(), url: z6.string(), headSha: z6.string(), title: z6.string() }).nullable(),
|
|
1608
|
+
specStatus: z6.enum(["draft", "validated", "implemented", "unreadable", "absent"]),
|
|
1609
|
+
checks: ChecksStateSchema,
|
|
1610
|
+
checkRuns: z6.array(z6.object({ name: z6.string(), conclusion: z6.string().nullable() })),
|
|
1611
|
+
mergeable: z6.boolean().nullable(),
|
|
1612
|
+
requiredRole: z6.enum(["approver", "operator"]),
|
|
1613
|
+
canMerge: z6.boolean(),
|
|
1614
|
+
/** Each thing standing between the PR and its merge, in the user's language. */
|
|
1615
|
+
blockers: z6.array(z6.string()),
|
|
1616
|
+
labels: z6.array(z6.string()).default([]),
|
|
1617
|
+
/** D52: the first schemas.ts of a new feature failed the gates twice — the operator has the hand (label needs-operator). */
|
|
1618
|
+
needsOperator: z6.boolean().default(false)
|
|
1619
|
+
});
|
|
1620
|
+
var SpecPullRequestsSchema = z6.object({
|
|
1621
|
+
pullRequests: z6.array(z6.object({ specId: FeatureNameSchema, number: z6.number().int().positive(), url: z6.string(), checks: ChecksStateSchema, needsOperator: z6.boolean().default(false) }))
|
|
1622
|
+
});
|
|
1623
|
+
var MergeOutputSchema = z6.object({
|
|
1624
|
+
specId: FeatureNameSchema,
|
|
1625
|
+
pullRequestNumber: z6.number().int().positive(),
|
|
1626
|
+
mergeSha: z6.string(),
|
|
1627
|
+
mergedBy: z6.string(),
|
|
1628
|
+
mergedAt: z6.string()
|
|
1629
|
+
});
|
|
1630
|
+
var EnvironmentNameSchema = z6.enum(["development", "staging", "production"]);
|
|
1631
|
+
var PromotableEnvironmentSchema = z6.enum(["staging", "production"]);
|
|
1632
|
+
var EnvironmentParamsSchema = z6.object({ environment: PromotableEnvironmentSchema });
|
|
1633
|
+
var EnvironmentsStatusSchema = z6.object({
|
|
1634
|
+
environments: z6.array(
|
|
1635
|
+
z6.object({
|
|
1636
|
+
name: EnvironmentNameSchema,
|
|
1637
|
+
ref: z6.string(),
|
|
1638
|
+
sha: z6.string().nullable(),
|
|
1639
|
+
deployment: z6.object({ id: z6.number().int(), state: z6.string().nullable(), createdAt: z6.string() }).nullable()
|
|
1640
|
+
})
|
|
1641
|
+
),
|
|
1642
|
+
/** release/<date>.<n>, most recent first. */
|
|
1643
|
+
releases: z6.array(z6.string())
|
|
1644
|
+
});
|
|
1645
|
+
var PromotionInputSchema = z6.object({
|
|
1646
|
+
sha: z6.string().regex(/^[0-9a-f]{40}$/),
|
|
1647
|
+
/** Operator only: a step back, forced, with its reason. */
|
|
1648
|
+
rollback: z6.boolean().optional(),
|
|
1649
|
+
reason: z6.string().min(10).max(2e3).optional()
|
|
1650
|
+
});
|
|
1651
|
+
var PromotionRecordSchema = z6.object({
|
|
1652
|
+
environment: PromotableEnvironmentSchema,
|
|
1653
|
+
fromSha: z6.string().nullable(),
|
|
1654
|
+
toSha: z6.string(),
|
|
1655
|
+
actor: z6.string(),
|
|
1656
|
+
at: z6.string(),
|
|
1657
|
+
deploymentId: z6.number().int(),
|
|
1658
|
+
releaseTag: z6.string().nullable(),
|
|
1659
|
+
includedPullRequests: z6.array(z6.object({ number: z6.number().int(), specId: z6.string().nullable(), message: z6.string(), authoredAt: z6.string() })),
|
|
1660
|
+
destructiveMigration: z6.boolean(),
|
|
1661
|
+
rollback: z6.boolean()
|
|
1662
|
+
});
|
|
1663
|
+
var ConsumptionQuerySchema = z6.object({ month: z6.string().regex(/^\d{4}-\d{2}$/).optional() });
|
|
1664
|
+
var ConsumptionReportSchema = z6.object({
|
|
1665
|
+
month: z6.string(),
|
|
1666
|
+
specs: z6.array(
|
|
1667
|
+
z6.object({
|
|
1668
|
+
specId: z6.string(),
|
|
1669
|
+
pullRequestNumber: z6.number().int(),
|
|
1670
|
+
runs: z6.number().int(),
|
|
1671
|
+
inputTokens: z6.number().int(),
|
|
1672
|
+
outputTokens: z6.number().int(),
|
|
1673
|
+
estimatedUsd: z6.number(),
|
|
1674
|
+
uncostedModels: z6.array(z6.string())
|
|
1675
|
+
})
|
|
1676
|
+
),
|
|
1677
|
+
totals: z6.object({ runs: z6.number().int(), inputTokens: z6.number().int(), outputTokens: z6.number().int(), estimatedUsd: z6.number() }),
|
|
1678
|
+
budgetUsd: z6.number().nullable(),
|
|
1679
|
+
budgetRatio: z6.number().nullable(),
|
|
1680
|
+
warning: z6.boolean()
|
|
1681
|
+
});
|
|
1682
|
+
var PreviewEntrySchema = z6.object({ state: z6.string(), path: z6.string(), sizeBytes: z6.number().int().nonnegative() });
|
|
1683
|
+
var PreviewsListSchema = z6.object({
|
|
1684
|
+
specId: FeatureNameSchema,
|
|
1685
|
+
/** In the order of the states: empty, loading, nominal, error, then the rest — the pages of the composition the head declares, or of the root. */
|
|
1686
|
+
previews: z6.array(PreviewEntrySchema),
|
|
1687
|
+
/** D53: the composition the spec at the head declares under `# Design`; null when the screen has one composition. */
|
|
1688
|
+
composition: z6.string().nullable().default(null),
|
|
1689
|
+
/** D53: every composition the branch rendered, `selected` for the head's. */
|
|
1690
|
+
compositions: z6.array(z6.object({ name: z6.string(), selected: z6.boolean(), previews: z6.array(PreviewEntrySchema) })).default([])
|
|
1691
|
+
});
|
|
1692
|
+
var SelectCompositionOutputSchema = z6.object({
|
|
1693
|
+
specId: FeatureNameSchema,
|
|
1694
|
+
composition: z6.string(),
|
|
1695
|
+
/** The head already declared it: nothing was written. */
|
|
1696
|
+
alreadySelected: z6.boolean()
|
|
1697
|
+
});
|
|
998
1698
|
|
|
999
1699
|
// src/server/routes.ts
|
|
1000
|
-
async function registerStudioRoutes(app,
|
|
1700
|
+
async function registerStudioRoutes(app, resolver) {
|
|
1001
1701
|
const route = app.withTypeProvider();
|
|
1702
|
+
const depsOf = (request) => typeof resolver === "function" ? resolver(request) : resolver;
|
|
1002
1703
|
route.route({
|
|
1003
1704
|
method: "GET",
|
|
1004
1705
|
url: "/api/specs",
|
|
1005
1706
|
schema: { response: { 200: ListSpecsOutputSchema } },
|
|
1006
|
-
handler: async (
|
|
1007
|
-
return reply.code(200).send({ specs: await listSpecSummaries(
|
|
1707
|
+
handler: async (request, reply) => {
|
|
1708
|
+
return reply.code(200).send({ specs: await listSpecSummaries(depsOf(request)) });
|
|
1008
1709
|
}
|
|
1009
1710
|
});
|
|
1010
1711
|
route.route({
|
|
@@ -1012,7 +1713,7 @@ async function registerStudioRoutes(app, deps) {
|
|
|
1012
1713
|
url: "/api/specs/:specId",
|
|
1013
1714
|
schema: { params: SpecParamsSchema, response: { 200: GetSpecOutputSchema } },
|
|
1014
1715
|
handler: async (request, reply) => {
|
|
1015
|
-
return reply.code(200).send(await getSpec(
|
|
1716
|
+
return reply.code(200).send(await getSpec(depsOf(request), request.params.specId));
|
|
1016
1717
|
}
|
|
1017
1718
|
});
|
|
1018
1719
|
route.route({
|
|
@@ -1024,7 +1725,7 @@ async function registerStudioRoutes(app, deps) {
|
|
|
1024
1725
|
response: { 200: UpdateSpecOutputSchema }
|
|
1025
1726
|
},
|
|
1026
1727
|
handler: async (request, reply) => {
|
|
1027
|
-
const spec = await saveSpec(
|
|
1728
|
+
const spec = await saveSpec(depsOf(request), request.params.specId, request.body);
|
|
1028
1729
|
return reply.code(200).send({ spec });
|
|
1029
1730
|
}
|
|
1030
1731
|
});
|
|
@@ -1036,7 +1737,7 @@ async function registerStudioRoutes(app, deps) {
|
|
|
1036
1737
|
response: { 201: CreateSpecOutputSchema }
|
|
1037
1738
|
},
|
|
1038
1739
|
handler: async (request, reply) => {
|
|
1039
|
-
const spec = await createSpec(
|
|
1740
|
+
const spec = await createSpec(depsOf(request), request.body.id);
|
|
1040
1741
|
return reply.code(201).send({ spec });
|
|
1041
1742
|
}
|
|
1042
1743
|
});
|
|
@@ -1044,28 +1745,1410 @@ async function registerStudioRoutes(app, deps) {
|
|
|
1044
1745
|
method: "GET",
|
|
1045
1746
|
url: "/api/manifest",
|
|
1046
1747
|
schema: { response: { 200: GetManifestOutputSchema } },
|
|
1047
|
-
handler: async (
|
|
1048
|
-
return reply.code(200).send({ manifest: await getManifest(
|
|
1748
|
+
handler: async (request, reply) => {
|
|
1749
|
+
return reply.code(200).send({ manifest: await getManifest(depsOf(request)) });
|
|
1049
1750
|
}
|
|
1050
1751
|
});
|
|
1051
1752
|
}
|
|
1052
1753
|
|
|
1053
|
-
// src/server/
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1754
|
+
// src/server/hosted/routes.ts
|
|
1755
|
+
import { z as z8 } from "zod";
|
|
1756
|
+
|
|
1757
|
+
// src/server/hosted/auth.ts
|
|
1758
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1759
|
+
var MAGIC_LINK_TTL_MS = 15 * 60 * 1e3;
|
|
1760
|
+
var SESSION_COOKIE = "ogygie_host_session";
|
|
1761
|
+
var base64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1762
|
+
var fromBase64url = (input) => Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
1763
|
+
function sign(payload, secret) {
|
|
1764
|
+
return base64url(createHmac("sha256", secret).update(payload).digest());
|
|
1765
|
+
}
|
|
1766
|
+
function seal(value, secret) {
|
|
1767
|
+
const payload = base64url(JSON.stringify(value));
|
|
1768
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
1769
|
+
}
|
|
1770
|
+
function open(token, secret) {
|
|
1771
|
+
const [payload, signature, ...rest] = token.split(".");
|
|
1772
|
+
if (payload === void 0 || signature === void 0 || rest.length > 0) return null;
|
|
1773
|
+
const expected = Buffer.from(sign(payload, secret));
|
|
1774
|
+
const given = Buffer.from(signature);
|
|
1775
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
|
|
1776
|
+
try {
|
|
1777
|
+
const parsed = JSON.parse(fromBase64url(payload).toString("utf8"));
|
|
1778
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
1779
|
+
} catch {
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
function issueMagicLink(deps, email) {
|
|
1784
|
+
const exp = deps.now() + (deps.ttlMs ?? MAGIC_LINK_TTL_MS);
|
|
1785
|
+
return seal({ kind: "magic-link", email: email.toLowerCase(), exp, nonce: randomBytes(16).toString("hex") }, deps.secret);
|
|
1786
|
+
}
|
|
1787
|
+
function consumeMagicLink(deps, token) {
|
|
1788
|
+
const now = deps.now();
|
|
1789
|
+
for (const [nonce2, exp2] of deps.consumed) if (exp2 <= now) deps.consumed.delete(nonce2);
|
|
1790
|
+
const value = open(token, deps.secret);
|
|
1791
|
+
const email = value?.["email"];
|
|
1792
|
+
const exp = value?.["exp"];
|
|
1793
|
+
const nonce = value?.["nonce"];
|
|
1794
|
+
const valid = value?.["kind"] === "magic-link" && typeof email === "string" && typeof exp === "number" && typeof nonce === "string" && exp > now && !deps.consumed.has(nonce);
|
|
1795
|
+
if (!valid) {
|
|
1796
|
+
return throwOgygieError(
|
|
1797
|
+
{
|
|
1798
|
+
code: "MAGIC_LINK_EXPIRED",
|
|
1799
|
+
message: "This sign-in link is invalid, expired or already used.",
|
|
1800
|
+
file: null,
|
|
1801
|
+
cause: value === null ? "signature or format invalid" : "expired or consumed",
|
|
1802
|
+
suggestedFix: "Ask for a new link from the sign-in screen; each link works once, for fifteen minutes."
|
|
1803
|
+
},
|
|
1804
|
+
401
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
deps.consumed.set(nonce, exp);
|
|
1808
|
+
return email;
|
|
1809
|
+
}
|
|
1810
|
+
function issueSession(deps, email) {
|
|
1811
|
+
return seal({ kind: "session", email: email.toLowerCase(), exp: deps.now() + deps.ttlHours * 36e5 }, deps.secret);
|
|
1812
|
+
}
|
|
1813
|
+
function readSession(deps, cookieHeader) {
|
|
1814
|
+
const token = cookieValue(cookieHeader, SESSION_COOKIE);
|
|
1815
|
+
if (token === null) return null;
|
|
1816
|
+
const value = open(token, deps.secret);
|
|
1817
|
+
const email = value?.["email"];
|
|
1818
|
+
const exp = value?.["exp"];
|
|
1819
|
+
if (value?.["kind"] !== "session" || typeof email !== "string" || typeof exp !== "number" || exp <= deps.now()) {
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1822
|
+
return email;
|
|
1823
|
+
}
|
|
1824
|
+
function cookieValue(cookieHeader, name) {
|
|
1825
|
+
if (cookieHeader === void 0) return null;
|
|
1826
|
+
for (const part of cookieHeader.split(";")) {
|
|
1827
|
+
const [key, ...rest] = part.trim().split("=");
|
|
1828
|
+
if (key === name) return decodeURIComponent(rest.join("="));
|
|
1829
|
+
}
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
function sessionCookie(value, ttlHours) {
|
|
1833
|
+
return `${SESSION_COOKIE}=${encodeURIComponent(value)}; Path=/; Max-Age=${ttlHours * 3600}; HttpOnly; Secure; SameSite=Lax`;
|
|
1834
|
+
}
|
|
1835
|
+
function clearedSessionCookie() {
|
|
1836
|
+
return `${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
1837
|
+
}
|
|
1838
|
+
var ROLE_RANK = { reader: 0, author: 1, approver: 2, operator: 3 };
|
|
1839
|
+
function hasRole(user, required) {
|
|
1840
|
+
return ROLE_RANK[user.role] >= ROLE_RANK[required];
|
|
1841
|
+
}
|
|
1842
|
+
function requireRole(user, required, action) {
|
|
1843
|
+
if (hasRole(user, required)) return;
|
|
1844
|
+
throwOgygieError(
|
|
1845
|
+
{
|
|
1846
|
+
code: "HOST_ROLE_FORBIDDEN",
|
|
1847
|
+
message: `"${action}" requires the ${required} role.`,
|
|
1848
|
+
file: null,
|
|
1849
|
+
cause: `${user.email} has role ${user.role}`,
|
|
1850
|
+
suggestedFix: `Ask the operator to grant the ${required} role in the instance configuration.`
|
|
1851
|
+
},
|
|
1852
|
+
403
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
function unauthenticated(cause) {
|
|
1856
|
+
return throwOgygieError(
|
|
1857
|
+
{
|
|
1858
|
+
code: "HOST_UNAUTHENTICATED",
|
|
1859
|
+
message: "This hosted studio requires a signed-in user.",
|
|
1860
|
+
file: null,
|
|
1861
|
+
cause,
|
|
1862
|
+
suggestedFix: "Sign in at /login \u2014 a magic link is sent to a declared email address."
|
|
1863
|
+
},
|
|
1864
|
+
401
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/server/hosted/config.ts
|
|
1869
|
+
import { z as z7 } from "zod";
|
|
1870
|
+
var HostRoleSchema = z7.enum(["reader", "author", "approver", "operator"]);
|
|
1871
|
+
var MergePolicySchema = z7.enum(["client", "operator"]);
|
|
1872
|
+
var HostUserSchema = z7.object({
|
|
1873
|
+
email: z7.email(),
|
|
1874
|
+
displayName: z7.string().min(1).max(120),
|
|
1875
|
+
role: HostRoleSchema
|
|
1876
|
+
});
|
|
1877
|
+
var HostConfigSchema = z7.object({
|
|
1878
|
+
repo: GithubCoordinatesSchema,
|
|
1879
|
+
githubApp: z7.object({
|
|
1880
|
+
appId: z7.string().min(1),
|
|
1881
|
+
installationId: z7.string().min(1),
|
|
1882
|
+
privateKeyPath: z7.string().min(1)
|
|
1883
|
+
}),
|
|
1884
|
+
users: z7.array(HostUserSchema).min(1),
|
|
1885
|
+
/** "operator" at onboarding: the agency keeps the merge until it hands it over (D48). */
|
|
1886
|
+
mergePolicy: MergePolicySchema.default("operator"),
|
|
1887
|
+
writeRateLimit: z7.number().int().positive().default(20),
|
|
1888
|
+
session: z7.object({
|
|
1889
|
+
secretPath: z7.string().min(1),
|
|
1890
|
+
ttlHours: z7.number().int().positive().max(168).default(24)
|
|
1891
|
+
}),
|
|
1892
|
+
mail: z7.object({
|
|
1893
|
+
fromAddress: z7.email(),
|
|
1894
|
+
/** HTTP endpoint of the mail provider: POST JSON { from, to, subject, text }. */
|
|
1895
|
+
providerUrl: z7.url(),
|
|
1896
|
+
apiKeyPath: z7.string().min(1)
|
|
1897
|
+
}),
|
|
1898
|
+
/** Base of the magic links — what the client's browser can reach. */
|
|
1899
|
+
publicUrl: z7.url(),
|
|
1900
|
+
/** Lot D (D49): the two promotable refs. Optional until that lot lands. */
|
|
1901
|
+
environments: z7.object({
|
|
1902
|
+
staging: z7.object({ ref: z7.literal("env/staging") }),
|
|
1903
|
+
production: z7.object({ ref: z7.literal("env/production") })
|
|
1904
|
+
}).optional(),
|
|
1905
|
+
/**
|
|
1906
|
+
* The client's model workspace (D50). Present, the brief → spec route exists
|
|
1907
|
+
* and the launcher requires OGYGIE_MODEL_API_KEY; absent, the instance never
|
|
1908
|
+
* calls a model. `specModel` is the `spec` model of D24.
|
|
1909
|
+
*/
|
|
1910
|
+
model: z7.object({
|
|
1911
|
+
specModel: z7.string().min(1).default("claude-fable-5-1"),
|
|
1912
|
+
workspaceId: z7.string().min(1).optional(),
|
|
1913
|
+
monthlyBudgetUsd: z7.number().positive().optional(),
|
|
1914
|
+
/** USD per million tokens, by model id — overrides the built-in table of the estimate (B8). */
|
|
1915
|
+
costPerMillionUsd: z7.record(z7.string(), z7.object({ input: z7.number().nonnegative(), output: z7.number().nonnegative() })).optional()
|
|
1916
|
+
}).optional()
|
|
1917
|
+
});
|
|
1918
|
+
function parseHostConfig(raw, configPath) {
|
|
1919
|
+
const parsed = HostConfigSchema.safeParse(raw);
|
|
1920
|
+
if (!parsed.success) {
|
|
1921
|
+
return throwOgygieError({
|
|
1922
|
+
code: "HOST_CONFIG_INVALID",
|
|
1923
|
+
message: "The hosted studio configuration does not match HostConfigSchema.",
|
|
1924
|
+
file: configPath,
|
|
1925
|
+
cause: parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join(" ; "),
|
|
1926
|
+
suggestedFix: "Fix the listed fields \u2014 see HostConfigSchema in @ogygie/studio (repo, githubApp, users, session, mail, publicUrl)."
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return parsed.data;
|
|
1930
|
+
}
|
|
1931
|
+
function findHostUser(config, email) {
|
|
1932
|
+
const wanted = email.trim().toLowerCase();
|
|
1933
|
+
return config.users.find((user) => user.email.toLowerCase() === wanted) ?? null;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// src/server/hosted/routes.ts
|
|
1937
|
+
var usersByRequest = /* @__PURE__ */ new WeakMap();
|
|
1938
|
+
function hostedUserOf(request) {
|
|
1939
|
+
const user = usersByRequest.get(request);
|
|
1940
|
+
if (user === void 0) return unauthenticated("no session attached to this request");
|
|
1941
|
+
return user;
|
|
1942
|
+
}
|
|
1943
|
+
var MagicLinkInputSchema = z8.object({ email: z8.email() });
|
|
1944
|
+
var CallbackQuerySchema = z8.object({ token: z8.string().min(1) });
|
|
1945
|
+
var MeOutputSchema = z8.object({
|
|
1946
|
+
email: z8.email(),
|
|
1947
|
+
displayName: z8.string(),
|
|
1948
|
+
role: HostRoleSchema
|
|
1949
|
+
});
|
|
1950
|
+
async function registerHostedRoutes(app, deps) {
|
|
1951
|
+
const sessions = {
|
|
1952
|
+
secret: deps.sessionSecret,
|
|
1953
|
+
now: deps.now,
|
|
1954
|
+
ttlHours: deps.config.session.ttlHours
|
|
1955
|
+
};
|
|
1956
|
+
const links = {
|
|
1957
|
+
secret: deps.sessionSecret,
|
|
1958
|
+
now: deps.now,
|
|
1959
|
+
consumed: deps.consumedLinks,
|
|
1960
|
+
...deps.magicLinkTtlMs === void 0 ? {} : { ttlMs: deps.magicLinkTtlMs }
|
|
1961
|
+
};
|
|
1962
|
+
const PUBLIC_ROUTES = /* @__PURE__ */ new Set(["/api/auth/magic-link", "/api/auth/callback", "/api/auth/logout"]);
|
|
1963
|
+
app.addHook("onRequest", async (request) => {
|
|
1964
|
+
const url = request.url.split("?")[0] ?? request.url;
|
|
1965
|
+
if (!url.startsWith("/api/") || PUBLIC_ROUTES.has(url)) return;
|
|
1966
|
+
const email = readSession(sessions, request.headers.cookie);
|
|
1967
|
+
if (email === null) return unauthenticated("missing or expired session cookie");
|
|
1968
|
+
const user = findHostUser(deps.config, email);
|
|
1969
|
+
if (user === null) return unauthenticated(`session for ${email}, no longer a declared user`);
|
|
1970
|
+
usersByRequest.set(request, user);
|
|
1971
|
+
});
|
|
1972
|
+
const route = app.withTypeProvider();
|
|
1973
|
+
route.route({
|
|
1974
|
+
method: "POST",
|
|
1975
|
+
url: "/api/auth/magic-link",
|
|
1976
|
+
schema: { body: MagicLinkInputSchema, response: { 202: z8.object({ accepted: z8.literal(true) }) } },
|
|
1977
|
+
handler: async (request, reply) => {
|
|
1978
|
+
const user = findHostUser(deps.config, request.body.email);
|
|
1979
|
+
if (user === null) {
|
|
1980
|
+
deps.log(JSON.stringify({ action: "magic-link", outcome: "HOST_USER_UNKNOWN" }));
|
|
1981
|
+
} else {
|
|
1982
|
+
const token = issueMagicLink(links, user.email);
|
|
1983
|
+
const link = `${deps.config.publicUrl.replace(/\/$/, "")}/api/auth/callback?token=${encodeURIComponent(token)}`;
|
|
1984
|
+
await deps.mail.send({
|
|
1985
|
+
to: user.email,
|
|
1986
|
+
subject: "Votre lien de connexion au studio",
|
|
1987
|
+
text: `Bonjour ${user.displayName},
|
|
1988
|
+
|
|
1989
|
+
Ouvrez ce lien pour vous connecter (valable quinze minutes, une seule fois) :
|
|
1990
|
+
${link}
|
|
1991
|
+
`
|
|
1992
|
+
});
|
|
1993
|
+
deps.log(JSON.stringify({ action: "magic-link", actor: user.email, outcome: "sent" }));
|
|
1994
|
+
}
|
|
1995
|
+
return reply.code(202).send({ accepted: true });
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
route.route({
|
|
1999
|
+
method: "GET",
|
|
2000
|
+
url: "/api/auth/callback",
|
|
2001
|
+
schema: { querystring: CallbackQuerySchema },
|
|
2002
|
+
handler: async (request, reply) => {
|
|
2003
|
+
const email = consumeMagicLink(links, request.query.token);
|
|
2004
|
+
const user = findHostUser(deps.config, email);
|
|
2005
|
+
if (user === null) return unauthenticated(`link for ${email}, no longer a declared user`);
|
|
2006
|
+
deps.log(JSON.stringify({ action: "sign-in", actor: user.email, outcome: "ok" }));
|
|
2007
|
+
return reply.header("set-cookie", sessionCookie(issueSession(sessions, user.email), deps.config.session.ttlHours)).redirect("/", 302);
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
route.route({
|
|
2011
|
+
method: "GET",
|
|
2012
|
+
url: "/api/auth/me",
|
|
2013
|
+
schema: { response: { 200: MeOutputSchema } },
|
|
2014
|
+
handler: async (request, reply) => {
|
|
2015
|
+
return reply.code(200).send(hostedUserOf(request));
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
route.route({
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
url: "/api/auth/logout",
|
|
2021
|
+
handler: async (_request, reply) => {
|
|
2022
|
+
return reply.header("set-cookie", clearedSessionCookie()).code(204).send();
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// src/server/hosted/brief.ts
|
|
2028
|
+
import { z as z9 } from "zod";
|
|
2029
|
+
import { checkSpec, generationGrammarCard, readManifestGeneration } from "@ogygie/generate";
|
|
2030
|
+
var TEMPLATE_PATH2 = "specs/_TEMPLATE.spec.md";
|
|
2031
|
+
var PRODUCT_RULES_PATH = "AGENTS.product.md";
|
|
2032
|
+
var MANIFEST_PATH2 = "ogygie.manifest.json";
|
|
2033
|
+
var DESIGN_TOKENS_PATH = "design/tokens.json";
|
|
2034
|
+
var PATTERNS_CATALOG_PATH = "design/patterns.md";
|
|
2035
|
+
var DESIGN_BRIEFS_DIR = "design/briefs";
|
|
2036
|
+
var MAX_ATTEMPTS = 2;
|
|
2037
|
+
var MAX_ATTEMPTS_GENERATED = 3;
|
|
2038
|
+
var MAX_COMPOSITIONS = 3;
|
|
2039
|
+
var COMPOSITION_SEPARATOR = "=== COMPOSITION ===";
|
|
2040
|
+
var DERIVED_BRIEF_MAX = 6e3;
|
|
2041
|
+
var BRIEF_IN_MESSAGE_MAX = 2e3;
|
|
2042
|
+
async function readIfPresent(storage, path) {
|
|
2043
|
+
return await storage.exists(path) ? storage.readFile(path) : null;
|
|
2044
|
+
}
|
|
2045
|
+
var ManifestRouteSchema = z9.looseObject({ method: z9.string(), path: z9.string(), schemaInput: z9.string().optional(), schemaOutput: z9.string().optional(), invalidates: z9.array(z9.string()).optional() });
|
|
2046
|
+
var ManifestForPromptSchema = z9.looseObject({
|
|
2047
|
+
product: z9.looseObject({ name: z9.string(), description: z9.string().optional() }).optional(),
|
|
2048
|
+
features: z9.array(z9.looseObject({ name: z9.string(), intent: z9.string().optional(), status: z9.string().optional(), spec: z9.string().nullable().optional(), routes: z9.array(ManifestRouteSchema).optional() })).optional()
|
|
2049
|
+
});
|
|
2050
|
+
async function manifestSummary(storage) {
|
|
2051
|
+
const raw = await readIfPresent(storage, MANIFEST_PATH2);
|
|
2052
|
+
const featureIds = /* @__PURE__ */ new Set();
|
|
2053
|
+
if (raw === null) return { text: "(manifeste absent)", featureIds, generation: null, features: [] };
|
|
2054
|
+
let parsed;
|
|
2055
|
+
let rawJson;
|
|
2056
|
+
try {
|
|
2057
|
+
rawJson = JSON.parse(raw);
|
|
2058
|
+
parsed = ManifestForPromptSchema.safeParse(rawJson);
|
|
2059
|
+
} catch {
|
|
2060
|
+
return { text: "(manifeste illisible)", featureIds, generation: null, features: [] };
|
|
2061
|
+
}
|
|
2062
|
+
if (!parsed.success) return { text: "(manifeste illisible)", featureIds, generation: null, features: [] };
|
|
2063
|
+
const generationRead = readManifestGeneration(rawJson);
|
|
2064
|
+
const generation = generationRead.ok ? generationRead.generation : null;
|
|
2065
|
+
const product = parsed.data.product;
|
|
2066
|
+
const lines = [
|
|
2067
|
+
product === void 0 ? "Produit : (non nomm\xE9)" : `Produit : ${product.name}${product.description === void 0 ? "" : ` \u2014 ${product.description}`}`,
|
|
2068
|
+
"Features d\xE9clar\xE9es au manifeste :"
|
|
2069
|
+
];
|
|
2070
|
+
for (const feature of parsed.data.features ?? []) {
|
|
2071
|
+
featureIds.add(feature.name);
|
|
2072
|
+
lines.push(`- ${feature.name} (${feature.status ?? "?"}) : ${feature.intent ?? ""}`);
|
|
2073
|
+
}
|
|
2074
|
+
return { text: lines.join("\n"), featureIds, generation, features: parsed.data.features ?? [] };
|
|
2075
|
+
}
|
|
2076
|
+
async function consumedFeaturesOf(storage, features, generation, consumedIds) {
|
|
2077
|
+
const consumed = /* @__PURE__ */ new Map();
|
|
2078
|
+
for (const featureId of consumedIds) {
|
|
2079
|
+
const entry = features.find((feature) => feature.name === featureId);
|
|
2080
|
+
if (entry === void 0) continue;
|
|
2081
|
+
const specPath = entry.spec ?? null;
|
|
2082
|
+
const markdown = specPath === null ? null : await readIfPresent(storage, specPath);
|
|
2083
|
+
const report = markdown === null || specPath === null ? null : checkSpec({ markdown, specPath, generation });
|
|
2084
|
+
consumed.set(featureId, {
|
|
2085
|
+
featureId,
|
|
2086
|
+
block: report?.kind === "feature" ? report.block : null,
|
|
2087
|
+
schemas: null,
|
|
2088
|
+
routes: (entry.routes ?? []).map((route) => ({ method: route.method, path: route.path, schemaInput: route.schemaInput ?? "", schemaOutput: route.schemaOutput ?? "", ...route.invalidates === void 0 ? {} : { invalidates: [...route.invalidates] } }))
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
return consumed;
|
|
2092
|
+
}
|
|
2093
|
+
async function judgeGeneration(input) {
|
|
2094
|
+
const first = checkSpec({ markdown: input.content, specPath: input.specPath, generation: input.generation });
|
|
2095
|
+
if (!first.generated) return { problems: [], tally: null };
|
|
2096
|
+
if (input.generation === null) return { problems: ["# G\xE9n\xE9ration: the product's manifest declares no generation block \u2014 remove the section, this product is not generated"], tally: null };
|
|
2097
|
+
const report = first.kind === "screen" && first.screen !== null ? checkSpec({ markdown: input.content, specPath: input.specPath, generation: input.generation, consumed: await consumedFeaturesOf(input.storage, input.features, input.generation, first.screen.consumes.map((consumed) => consumed.feature)) }) : first;
|
|
2098
|
+
const problems = report.problems.map((problem) => `${problem.code}${problem.details?.["path"] === void 0 ? "" : ` ${String(problem.details["path"])}`}${problem.details?.["line"] === void 0 ? "" : ` (line ${String(problem.details["line"])})`}: ${problem.cause}`);
|
|
2099
|
+
return { problems, tally: { derivable: report.tally.derivable, hole: report.tally.hole } };
|
|
2100
|
+
}
|
|
2101
|
+
async function existingSpecIds(storage) {
|
|
2102
|
+
const entries = await storage.listDirectory("specs");
|
|
2103
|
+
return entries.map((entry) => entry.path).filter((path) => !path.includes("/") && path.endsWith(".spec.md") && !path.startsWith("_")).map((path) => path.replace(/\.spec\.md$/, "")).sort();
|
|
2104
|
+
}
|
|
2105
|
+
function buildBriefPrompt(context) {
|
|
2106
|
+
const system = [
|
|
2107
|
+
"Tu r\xE9diges des SPECS de features pour un produit construit avec le framework Ogygie. Une spec est un contrat, relu et sign\xE9 par un humain non d\xE9veloppeur, puis impl\xE9ment\xE9 par une cha\xEEne de g\xE9n\xE9ration : tu \xE9cris pour ce lecteur, en fran\xE7ais, pr\xE9cis, sans jargon.",
|
|
2108
|
+
"",
|
|
2109
|
+
"FORME OBLIGATOIRE \u2014 le fichier entier, et rien d'autre (pas de cl\xF4ture ``` autour, pas de commentaire avant ou apr\xE8s) :",
|
|
2110
|
+
"- un frontmatter YAML entre deux lignes `---`, au format RESTREINT : une cl\xE9 par ligne, scalaires ou tableaux de scalaires entre crochets, jamais d'objet imbriqu\xE9 ; cl\xE9s exactes : id (kebab-case, identique au nom du fichier), status (toujours `draft`), depends_on (tableau d'ids de specs existantes), priority (P0, P1 ou P2), refs (tableau de cha\xEEnes) ; `deferred` seulement si non vide, au format \"<RAISON> blockedBy:<id-kebab>\".",
|
|
2111
|
+
"- puis SIX sections, dans cet ordre exact, titres au niveau `# ` : `# @intent`, `# Comportements`, `# Contrats de donn\xE9es`, `# \xC9tats UI`, `# Non-objectifs`, `# Budget`.",
|
|
2112
|
+
"- Comportements : une liste de triplets `- \xC9TANT DONN\xC9 <contexte>` / `QUAND <action>` / `ALORS <r\xE9sultat observable>`, un par ligne de puce, en fran\xE7ais lisible. Les codes d'erreur, noms de sch\xE9mas et valeurs d'enum sont en ANGLAIS (ex. ORDER_REFERENCE_ALREADY_USED, CreateOrderInputSchema).",
|
|
2113
|
+
"- Contrats de donn\xE9es : esquisse des sch\xE9mas (noms suffix\xE9s Schema, champs et types), jamais de code TypeScript complet.",
|
|
2114
|
+
"- \xC9tats UI : vide / chargement / nominal / erreur, et ce que chacun montre.",
|
|
2115
|
+
"- Non-objectifs : ce que la feature ne fait PAS.",
|
|
2116
|
+
"- Budget : performance, d\xE9pendances (aucune nouvelle sans justification).",
|
|
2117
|
+
"",
|
|
2118
|
+
"R\xC8GLES DE FOND :",
|
|
2119
|
+
"- Tu ne cites une d\xE9cision `D<n>` QUE si elle figure dans la liste des d\xE9cisions connues ci-dessous ; sinon n'en cite aucune.",
|
|
2120
|
+
"- Tu n'inventes ni table, ni route, ni feature existante : tu t'appuies sur la carte du produit ci-dessous ; une rubrique nouvelle nomme ses propres contrats.",
|
|
2121
|
+
"- Tu n'\xE9cris jamais de code ni de design (couleurs, composants) : la spec dit QUOI, jamais COMMENT. Le bloc YAML de `# G\xE9n\xE9ration` n'est pas du code : c'est la d\xE9claration que le g\xE9n\xE9rateur lit.",
|
|
2122
|
+
...context.grammarCard === null || context.grammarCard === void 0 ? [] : ["- Quand la rubrique apporte des op\xE9rations ou un \xE9cran que le g\xE9n\xE9rateur sait d\xE9river, \xE9cris la section `# G\xE9n\xE9ration` apr\xE8s `# @intent` et ses `# Comportements` dans la grammaire ferm\xE9e (carte ci-dessous) : le brouillon est jug\xE9 par le v\xE9rificateur du g\xE9n\xE9rateur avant d'\xEAtre montr\xE9. Quand elle ne se g\xE9n\xE8re pas, n'\xE9cris pas la section."],
|
|
2123
|
+
"- Tu ne proposes jamais `status: validated` ni `implemented` : valider est le geste de l'humain.",
|
|
2124
|
+
"- Quand la rubrique est un \xC9CRAN (une page du tableau de bord), ajoute apr\xE8s `# Budget` une section `# Design` qui commence par une ligne `Composition : <nom-kebab>` (le nom de la disposition, ex. `liste-d-abord`), puis l'ordre des vues, le pattern du catalogue de chacune (liste, formulaire, \xE9tat vide\u2026) et les \xE9tats mis en avant \u2014 jamais une couleur, jamais un composant invent\xE9.",
|
|
2125
|
+
"",
|
|
2126
|
+
"GABARIT DU REPO (\xE0 respecter \xE0 la lettre) :",
|
|
2127
|
+
context.template,
|
|
2128
|
+
"",
|
|
2129
|
+
"R\xC8GLES PRODUIT (AGENTS.product.md) :",
|
|
2130
|
+
context.productRules ?? "(aucune r\xE8gle produit d\xE9clar\xE9e)",
|
|
2131
|
+
"",
|
|
2132
|
+
"CARTE DU PRODUIT :",
|
|
2133
|
+
context.manifestText,
|
|
2134
|
+
"",
|
|
2135
|
+
`SPECS EXISTANTES (ids) : ${context.specIds.join(", ") || "(aucune)"}`,
|
|
2136
|
+
`D\xC9CISIONS CONNUES (seules citables) : ${context.knownDecisionIds.join(", ") || "(aucune)"}`,
|
|
2137
|
+
...context.grammarCard === null || context.grammarCard === void 0 ? [] : ["", context.grammarCard]
|
|
2138
|
+
].join("\n");
|
|
2139
|
+
const user = [
|
|
2140
|
+
context.existingSpec === null ? context.specId === void 0 ? "R\xE9dige une spec NOUVELLE \xE0 partir du brief ci-dessous. Choisis un id kebab-case en fran\xE7ais, court, qui ne soit pas d\xE9j\xE0 pris." : `R\xE9dige une spec NOUVELLE d'id \`${context.specId}\` \xE0 partir du brief ci-dessous.` : `AMENDE la spec existante \`${context.specId ?? ""}\` ci-dessous selon le brief : conserve ce qui n'est pas remis en cause, r\xE9\xE9cris ce qui l'est, et d\xE9cris toujours l'\xE9tat voulu complet (jamais un delta). Garde \`status: draft\`.`,
|
|
2141
|
+
"",
|
|
2142
|
+
...context.existingSpec === null ? [] : ["SPEC EXISTANTE :", context.existingSpec, ""],
|
|
2143
|
+
"BRIEF :",
|
|
2144
|
+
context.brief.trim()
|
|
2145
|
+
].join("\n");
|
|
2146
|
+
return { system, user };
|
|
2147
|
+
}
|
|
2148
|
+
function designContextOf(material) {
|
|
2149
|
+
const lines = [];
|
|
2150
|
+
if (material.tokensJson !== null) {
|
|
2151
|
+
try {
|
|
2152
|
+
const tokens2 = JSON.parse(material.tokensJson);
|
|
2153
|
+
for (const [group, value] of Object.entries(tokens2)) {
|
|
2154
|
+
if (group.startsWith("$") || typeof value !== "object" || value === null) continue;
|
|
2155
|
+
lines.push(`- ${group} : ${Array.isArray(value) ? value.join(", ") : Object.keys(value).join(", ")}`);
|
|
2156
|
+
}
|
|
2157
|
+
} catch {
|
|
2158
|
+
lines.push("- (design/tokens.json illisible)");
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
const tokens = lines.length === 0 ? "(aucun token d\xE9clar\xE9)" : lines.join("\n");
|
|
2162
|
+
const patterns = material.patternsCatalog === null ? "(aucun catalogue de patterns)" : [...material.patternsCatalog.matchAll(/^## +(.+?) *$\n+([^\n]*)/gmu)].map((match) => `- ${match[1] ?? ""} : ${(match[2] ?? "").split(/(?<=\.)\s/u)[0] ?? ""}`.slice(0, 220)).join("\n") || "(catalogue sans fiche)";
|
|
2163
|
+
const derived = material.derivedBrief === null ? "" : `
|
|
2164
|
+
BRIEF DE DESIGN D\xC9RIV\xC9 DE LA FEATURE :
|
|
2165
|
+
${material.derivedBrief.length > DERIVED_BRIEF_MAX ? `${material.derivedBrief.slice(0, DERIVED_BRIEF_MAX)} (\u2026)` : material.derivedBrief}
|
|
2166
|
+
`;
|
|
2167
|
+
return `TOKENS DE DESIGN (seuls noms employables) :
|
|
2168
|
+
${tokens}
|
|
2169
|
+
|
|
2170
|
+
CATALOGUE DES PATTERNS (seules briques d'\xE9cran) :
|
|
2171
|
+
${patterns}
|
|
2172
|
+
${derived}`;
|
|
2173
|
+
}
|
|
2174
|
+
function buildCompositionsPrompt(input) {
|
|
2175
|
+
const others = MAX_COMPOSITIONS - 1;
|
|
2176
|
+
return {
|
|
2177
|
+
system: `${input.system}
|
|
2178
|
+
|
|
2179
|
+
${input.designContext}`,
|
|
2180
|
+
user: [
|
|
2181
|
+
`La spec ci-dessous d\xE9crit un \xE9cran dont la composition retenue par d\xE9faut s'appelle \`${input.composition}\`. Propose jusqu'\xE0 ${others} AUTRES compositions de ce m\xEAme \xE9cran \u2014 un autre ordre des vues, un autre pattern par vue, d'autres \xE9tats mis en avant \u2014 sans changer les comportements, les contrats ni l'id.`,
|
|
2182
|
+
`R\xE9ponds avec, pour chacune, la SPEC COMPL\xC8TE (frontmatter et les six sections, puis \`# Design\` avec sa propre ligne \`Composition : <nom-kebab>\`, un nom diff\xE9rent), les specs s\xE9par\xE9es par une ligne contenant exactement \`${COMPOSITION_SEPARATOR}\`. Rien d'autre. Si une seule disposition a du sens, r\xE9ponds \`${COMPOSITION_SEPARATOR}\` seul.`,
|
|
2183
|
+
"",
|
|
2184
|
+
"SPEC RETENUE :",
|
|
2185
|
+
input.specText.trim()
|
|
2186
|
+
].join("\n")
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
function splitCompositions(answer) {
|
|
2190
|
+
return answer.split(COMPOSITION_SEPARATOR).map((part) => part.trim()).filter((part) => part.includes("---")).map((part) => extractSpecText(part));
|
|
2191
|
+
}
|
|
2192
|
+
function extractSpecText(answer) {
|
|
2193
|
+
const whole = answer.trim();
|
|
2194
|
+
let candidate = whole;
|
|
2195
|
+
if (!whole.startsWith("---")) {
|
|
2196
|
+
const opening = [...answer.matchAll(/```(?:markdown|md|yaml)?[^\n]*\n/g)].find((fence) => answer.slice((fence.index ?? 0) + fence[0].length).trimStart().startsWith("---"));
|
|
2197
|
+
if (opening !== void 0) {
|
|
2198
|
+
const from = (opening.index ?? 0) + opening[0].length;
|
|
2199
|
+
const to = answer.lastIndexOf("```");
|
|
2200
|
+
candidate = (to > from ? answer.slice(from, to) : answer.slice(from)).trim();
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
const start = candidate.indexOf("---");
|
|
2204
|
+
const text = start === -1 ? candidate : candidate.slice(start);
|
|
2205
|
+
return text.endsWith("\n") ? text : `${text}
|
|
2206
|
+
`;
|
|
2207
|
+
}
|
|
2208
|
+
function judgeDraft(input) {
|
|
2209
|
+
const parsed = parseSpecDocument(input.content);
|
|
2210
|
+
const problems = [];
|
|
2211
|
+
const specId = parsed.frontmatter?.id ?? null;
|
|
2212
|
+
if (specId === null) {
|
|
2213
|
+
problems.push(...parsed.problems.map((problem) => `frontmatter: ${problem}`));
|
|
2214
|
+
return { specId, problems };
|
|
2215
|
+
}
|
|
2216
|
+
if (input.expectedId !== void 0 && specId !== input.expectedId) {
|
|
2217
|
+
problems.push(`id "${specId}" was asked as "${input.expectedId}"`);
|
|
2218
|
+
}
|
|
2219
|
+
if (!input.isAmendment && input.takenIds.includes(specId)) {
|
|
2220
|
+
problems.push(`id "${specId}" is already taken by an existing spec \u2014 choose another id`);
|
|
2221
|
+
}
|
|
2222
|
+
if (parsed.frontmatter?.status !== "draft") {
|
|
2223
|
+
problems.push(`status must be draft (got ${parsed.frontmatter?.status ?? "none"})`);
|
|
2224
|
+
}
|
|
2225
|
+
try {
|
|
2226
|
+
assertSpecProposable(`specs/${specId}.spec.md`, input.content);
|
|
2227
|
+
} catch (thrown) {
|
|
2228
|
+
if (thrown instanceof OgygieFailure) problems.push(thrown.error.cause ?? thrown.error.message);
|
|
2229
|
+
else throw thrown;
|
|
2230
|
+
}
|
|
2231
|
+
for (const reference of findUnknownDecisionReferences(input.content, input.knownDecisionIds)) {
|
|
2232
|
+
problems.push(`${reference.decision} (line ${reference.line}) is not a declared decision \u2014 cite none, or a known one`);
|
|
2233
|
+
}
|
|
2234
|
+
return { specId, problems: [...new Set(problems)] };
|
|
2235
|
+
}
|
|
2236
|
+
async function draftSpecFromBrief(deps, input) {
|
|
2237
|
+
if (input.specId !== void 0 && !FeatureNameSchema.safeParse(input.specId).success) {
|
|
2238
|
+
throwOgygieError(
|
|
2239
|
+
{
|
|
2240
|
+
code: "CONTRACT_VALIDATION_FAILED",
|
|
2241
|
+
message: "The spec id must be kebab-case.",
|
|
2242
|
+
file: null,
|
|
2243
|
+
cause: `specId=${input.specId}`,
|
|
2244
|
+
suggestedFix: "Use lowercase words separated by hyphens, e.g. suivi-des-commandes."
|
|
2245
|
+
},
|
|
2246
|
+
400
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
const template = await readIfPresent(deps.storage, TEMPLATE_PATH2);
|
|
2250
|
+
if (template === null) {
|
|
2251
|
+
throwOgygieError({
|
|
2252
|
+
code: "SPEC_TEMPLATE_NOT_FOUND",
|
|
2253
|
+
message: "specs/_TEMPLATE.spec.md is missing \u2014 the model writes from it.",
|
|
2254
|
+
file: TEMPLATE_PATH2,
|
|
2255
|
+
cause: "template file unreadable",
|
|
2256
|
+
suggestedFix: "Restore specs/_TEMPLATE.spec.md from the framework skeleton."
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
const [productRules, manifest, specIds] = await Promise.all([
|
|
2260
|
+
readIfPresent(deps.storage, PRODUCT_RULES_PATH),
|
|
2261
|
+
manifestSummary(deps.storage),
|
|
2262
|
+
existingSpecIds(deps.storage)
|
|
2263
|
+
]);
|
|
2264
|
+
const knownDecisionIds = (await Promise.all(DECISION_FILES.map((file) => readIfPresent(deps.storage, file)))).flatMap((content) => parseKnownDecisionIds(content ?? ""));
|
|
2265
|
+
const existingSpec = input.specId !== void 0 ? await readIfPresent(deps.storage, `specs/${input.specId}.spec.md`) : null;
|
|
2266
|
+
const isAmendment = existingSpec !== null;
|
|
2267
|
+
const prompt = buildBriefPrompt({
|
|
2268
|
+
template,
|
|
2269
|
+
productRules,
|
|
2270
|
+
manifestText: manifest.text,
|
|
2271
|
+
grammarCard: manifest.generation === null ? null : generationGrammarCard(manifest.generation),
|
|
2272
|
+
specIds,
|
|
2273
|
+
knownDecisionIds,
|
|
2274
|
+
existingSpec,
|
|
2275
|
+
specId: input.specId,
|
|
2276
|
+
brief: input.brief
|
|
2277
|
+
});
|
|
2278
|
+
const usage = [];
|
|
2279
|
+
let previous;
|
|
2280
|
+
let firstProblems = [];
|
|
2281
|
+
let maxAttempts = MAX_ATTEMPTS;
|
|
2282
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
2283
|
+
const answer = await deps.writer.write({ ...prompt, previous });
|
|
2284
|
+
usage.push({ model: answer.model, ...answer.usage });
|
|
2285
|
+
const content = extractSpecText(answer.text);
|
|
2286
|
+
const judged = judgeDraft({
|
|
2287
|
+
content,
|
|
2288
|
+
expectedId: input.specId,
|
|
2289
|
+
knownDecisionIds,
|
|
2290
|
+
takenIds: specIds,
|
|
2291
|
+
isAmendment
|
|
2292
|
+
});
|
|
2293
|
+
const generationJudgement = judged.problems.length === 0 && judged.specId !== null ? await judgeGeneration({ content, specPath: `specs/${judged.specId}.spec.md`, generation: manifest.generation, storage: deps.storage, features: manifest.features }) : { problems: [], tally: null };
|
|
2294
|
+
judged.problems.push(...generationJudgement.problems);
|
|
2295
|
+
if (generationJudgement.tally !== null) maxAttempts = MAX_ATTEMPTS_GENERATED;
|
|
2296
|
+
deps.log(JSON.stringify({ action: "brief", attempt, specId: judged.specId, problems: judged.problems.length, generated: generationJudgement.tally !== null, ...answer.usage }));
|
|
2297
|
+
if (judged.problems.length === 0 && judged.specId !== null) {
|
|
2298
|
+
const specId = judged.specId;
|
|
2299
|
+
const briefForMessage = input.brief.length > BRIEF_IN_MESSAGE_MAX ? `${input.brief.slice(0, BRIEF_IN_MESSAGE_MAX)} (\u2026)` : input.brief;
|
|
2300
|
+
const composition = isAmendment ? null : compositionOfSpec(content);
|
|
2301
|
+
const compositions = [];
|
|
2302
|
+
if (composition !== null) {
|
|
2303
|
+
const [tokensJson, patternsCatalog, derivedBrief] = await Promise.all([
|
|
2304
|
+
readIfPresent(deps.storage, DESIGN_TOKENS_PATH),
|
|
2305
|
+
readIfPresent(deps.storage, PATTERNS_CATALOG_PATH),
|
|
2306
|
+
readIfPresent(deps.storage, `${DESIGN_BRIEFS_DIR}/${specId}.md`)
|
|
2307
|
+
]);
|
|
2308
|
+
let answerText = "";
|
|
2309
|
+
try {
|
|
2310
|
+
const answer2 = await deps.writer.write(buildCompositionsPrompt({ system: prompt.system, specText: content, composition, designContext: designContextOf({ tokensJson, patternsCatalog, derivedBrief }) }));
|
|
2311
|
+
usage.push({ model: answer2.model, ...answer2.usage });
|
|
2312
|
+
answerText = answer2.text;
|
|
2313
|
+
} catch (thrown) {
|
|
2314
|
+
deps.log(JSON.stringify({ action: "composition", specId, outcome: thrown instanceof OgygieFailure ? thrown.error.code : "error" }));
|
|
2315
|
+
}
|
|
2316
|
+
const seen = /* @__PURE__ */ new Set([composition]);
|
|
2317
|
+
for (const candidate of splitCompositions(answerText)) {
|
|
2318
|
+
if (seen.size >= MAX_COMPOSITIONS) break;
|
|
2319
|
+
const name = compositionOfSpec(candidate);
|
|
2320
|
+
const verdict = judgeDraft({ content: candidate, expectedId: specId, knownDecisionIds, takenIds: specIds, isAmendment: false });
|
|
2321
|
+
const problems = name === null ? ["# Design names no composition"] : seen.has(name) ? [`composition "${name}" is already proposed`] : verdict.problems;
|
|
2322
|
+
deps.log(JSON.stringify({ action: "composition", specId, composition: name, problems: problems.length }));
|
|
2323
|
+
if (problems.length > 0 || name === null) continue;
|
|
2324
|
+
seen.add(name);
|
|
2325
|
+
await deps.storage.write(`specs/${specId}.spec.md`, candidate, `spec: composition ${name} pour ${specId}, propos\xE9e par le mod\xE8le
|
|
2326
|
+
|
|
2327
|
+
Une autre disposition du m\xEAme \xE9cran ; la composition retenue par d\xE9faut suit dans le commit suivant.
|
|
2328
|
+
|
|
2329
|
+
Brief :
|
|
2330
|
+
${briefForMessage}`);
|
|
2331
|
+
compositions.push(name);
|
|
2332
|
+
}
|
|
2333
|
+
compositions.unshift(composition);
|
|
2334
|
+
}
|
|
2335
|
+
await deps.storage.write(
|
|
2336
|
+
`specs/${specId}.spec.md`,
|
|
2337
|
+
content,
|
|
2338
|
+
`spec: ${isAmendment ? "amend" : "draft"} ${specId} depuis un brief
|
|
2339
|
+
|
|
2340
|
+
Brief :
|
|
2341
|
+
${briefForMessage}`
|
|
2342
|
+
);
|
|
2343
|
+
return {
|
|
2344
|
+
specId,
|
|
2345
|
+
attempts: attempt,
|
|
2346
|
+
problemsFixed: firstProblems,
|
|
2347
|
+
isNewFeature: !manifest.featureIds.has(specId),
|
|
2348
|
+
isAmendment,
|
|
2349
|
+
compositions,
|
|
2350
|
+
generation: generationJudgement.tally,
|
|
2351
|
+
usage
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
if (attempt === 1) firstProblems = judged.problems;
|
|
2355
|
+
previous = { draft: answer.text, problems: judged.problems };
|
|
2356
|
+
}
|
|
2357
|
+
return throwOgygieError(
|
|
2358
|
+
{
|
|
2359
|
+
code: "SPEC_DRAFT_REJECTED",
|
|
2360
|
+
message: `The model's draft still fails the repo's gates after ${maxAttempts} attempts; nothing was written.`,
|
|
2361
|
+
file: input.specId === void 0 ? null : `specs/${input.specId}.spec.md`,
|
|
2362
|
+
cause: (previous?.problems ?? []).join(" ; "),
|
|
2363
|
+
suggestedFix: "Rephrase the brief (one rubric, concrete behaviours, no code), or write the spec by hand from the template.",
|
|
2364
|
+
details: { problems: previous?.problems ?? [], usage }
|
|
2365
|
+
},
|
|
2366
|
+
422
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/server/hosted/brief-routes.ts
|
|
2371
|
+
async function registerBriefRoutes(app, deps) {
|
|
2372
|
+
const route = app.withTypeProvider();
|
|
2373
|
+
route.route({
|
|
2374
|
+
method: "POST",
|
|
2375
|
+
url: "/api/briefs",
|
|
2376
|
+
schema: { body: BriefInputSchema, response: { 201: BriefOutputSchema } },
|
|
2377
|
+
handler: async (request, reply) => {
|
|
2378
|
+
const user = hostedUserOf(request);
|
|
2379
|
+
requireRole(user, "author", "write a brief");
|
|
2380
|
+
const storage = deps.storageFor(user);
|
|
2381
|
+
const result = await draftSpecFromBrief(
|
|
2382
|
+
{ storage, writer: deps.writer, log: deps.log },
|
|
2383
|
+
{ brief: request.body.brief, specId: request.body.specId }
|
|
2384
|
+
);
|
|
2385
|
+
const proposal = "lastProposal" in storage ? storage.lastProposal : null;
|
|
2386
|
+
if (deps.usage !== void 0 && proposal !== null) {
|
|
2387
|
+
const at = new Date((deps.now ?? Date.now)()).toISOString();
|
|
2388
|
+
try {
|
|
2389
|
+
await deps.usage.appendUsage(
|
|
2390
|
+
proposal.pullRequestNumber,
|
|
2391
|
+
result.usage.map((usage, index) => ({ at, action: "brief", attempt: index + 1, model: usage.model, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, actor: user.email, specId: result.specId }))
|
|
2392
|
+
);
|
|
2393
|
+
} catch (thrown) {
|
|
2394
|
+
deps.log(JSON.stringify({ action: "usage-journal", specId: result.specId, outcome: thrown instanceof Error ? thrown.message : String(thrown) }));
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return reply.code(201).send(result);
|
|
2398
|
+
}
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// src/server/hosted/previews.ts
|
|
2403
|
+
var PREVIEWS_DIR = "previews";
|
|
2404
|
+
var STATE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
2405
|
+
var STATE_ORDER = ["empty", "loading", "nominal", "error"];
|
|
2406
|
+
function previewsDirOf(specId, composition = null) {
|
|
2407
|
+
return composition === null ? `${PREVIEWS_DIR}/${specId}` : `${PREVIEWS_DIR}/${specId}/${composition}`;
|
|
2408
|
+
}
|
|
2409
|
+
function previewSpecIdOf(path) {
|
|
2410
|
+
const match = /^previews\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\//.exec(path);
|
|
2411
|
+
return match?.[1] ?? null;
|
|
2412
|
+
}
|
|
2413
|
+
function orderStates(states) {
|
|
2414
|
+
return [...states].sort((a, b) => {
|
|
2415
|
+
const ia = STATE_ORDER.indexOf(a);
|
|
2416
|
+
const ib = STATE_ORDER.indexOf(b);
|
|
2417
|
+
if (ia !== -1 || ib !== -1) return (ia === -1 ? STATE_ORDER.length : ia) - (ib === -1 ? STATE_ORDER.length : ib) || a.localeCompare(b);
|
|
2418
|
+
return a.localeCompare(b);
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
function orderedPages(pages) {
|
|
2422
|
+
const byState = new Map(pages.map((entry) => [entry.state, entry]));
|
|
2423
|
+
return orderStates([...byState.keys()]).map((state) => byState.get(state));
|
|
2424
|
+
}
|
|
2425
|
+
async function listPreviews(storage, specId, composition = null) {
|
|
2426
|
+
const dir = previewsDirOf(specId, composition);
|
|
2427
|
+
const entries = await storage.listDirectory(dir);
|
|
2428
|
+
return orderedPages(
|
|
2429
|
+
entries.filter((entry) => !entry.path.includes("/") && entry.path.endsWith(".html")).map((entry) => ({ state: entry.path.replace(/\.html$/, ""), path: `${dir}/${entry.path}`, sizeBytes: entry.sizeBytes })).filter((entry) => STATE_NAME.test(entry.state))
|
|
2430
|
+
);
|
|
2431
|
+
}
|
|
2432
|
+
async function listPreviewCompositions(storage, specId) {
|
|
2433
|
+
const dir = previewsDirOf(specId);
|
|
2434
|
+
const entries = await storage.listDirectory(dir);
|
|
2435
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2436
|
+
for (const entry of entries) {
|
|
2437
|
+
const segments = entry.path.split("/");
|
|
2438
|
+
if (segments.length !== 2) continue;
|
|
2439
|
+
const [name, file] = segments;
|
|
2440
|
+
if (name === void 0 || file === void 0 || !STATE_NAME.test(name) || !file.endsWith(".html")) continue;
|
|
2441
|
+
const state = file.replace(/\.html$/, "");
|
|
2442
|
+
if (!STATE_NAME.test(state)) continue;
|
|
2443
|
+
const pages = byName.get(name) ?? [];
|
|
2444
|
+
pages.push({ state, path: `${dir}/${name}/${file}`, sizeBytes: entry.sizeBytes });
|
|
2445
|
+
byName.set(name, pages);
|
|
2446
|
+
}
|
|
2447
|
+
return [...byName.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, pages]) => ({ name, previews: orderedPages(pages) }));
|
|
2448
|
+
}
|
|
2449
|
+
async function readPreview(storage, specId, state, composition = null) {
|
|
2450
|
+
if (composition !== null && !STATE_NAME.test(composition)) {
|
|
2451
|
+
throwOgygieError(
|
|
2452
|
+
{ code: "PREVIEW_NOT_FOUND", message: `"${composition}" is not a composition name.`, file: null, cause: "a composition is named in kebab-case", suggestedFix: "Pick a composition from GET /api/specs/<id>/previews." },
|
|
2453
|
+
404
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
if (!STATE_NAME.test(state)) {
|
|
2457
|
+
throwOgygieError(
|
|
2458
|
+
{ code: "PREVIEW_NOT_FOUND", message: `"${state}" is not a preview state name.`, file: null, cause: "state must be lowercase letters, digits and hyphens", suggestedFix: "Pick a state from GET /api/specs/<id>/previews." },
|
|
2459
|
+
404
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
const path = `${previewsDirOf(specId, composition)}/${state}.html`;
|
|
2463
|
+
if (!await storage.exists(path)) {
|
|
2464
|
+
throwOgygieError(
|
|
2465
|
+
{ code: "PREVIEW_NOT_FOUND", message: `Spec "${specId}" has no preview for state "${state}"${composition === null ? "" : ` in composition "${composition}"`}.`, file: path, cause: "no such file on the spec's working copy", suggestedFix: "The speculate job renders previews when the generator knows the screen; see GET /api/specs/<id>/previews for what exists." },
|
|
2466
|
+
404
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
return storage.readFile(path);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// src/server/hosted/github-storage.ts
|
|
2473
|
+
var SPEC_PATH = /^specs\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\.spec\.md$/;
|
|
2474
|
+
function specIdOfPath2(path) {
|
|
2475
|
+
return SPEC_PATH.exec(path)?.[1] ?? null;
|
|
2476
|
+
}
|
|
2477
|
+
function specBranchOf(specId) {
|
|
2478
|
+
return `spec/${specId}`;
|
|
2479
|
+
}
|
|
2480
|
+
function slug(path) {
|
|
2481
|
+
return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
2482
|
+
}
|
|
2483
|
+
function createHostedSpecStorage(deps) {
|
|
2484
|
+
const defaultReader = deps.readerFor(deps.github.defaultBranch);
|
|
2485
|
+
const branchReaders = /* @__PURE__ */ new Map();
|
|
2486
|
+
let branchesPromise = null;
|
|
2487
|
+
const branches = () => {
|
|
2488
|
+
branchesPromise ??= deps.listSpecBranches().then((names) => new Set(names));
|
|
2489
|
+
return branchesPromise;
|
|
2490
|
+
};
|
|
2491
|
+
const readerOfBranch = (branch) => {
|
|
2492
|
+
let reader = branchReaders.get(branch);
|
|
2493
|
+
if (reader === void 0) {
|
|
2494
|
+
reader = deps.readerFor(branch);
|
|
2495
|
+
branchReaders.set(branch, reader);
|
|
2496
|
+
}
|
|
2497
|
+
return reader;
|
|
2498
|
+
};
|
|
2499
|
+
async function readerOf(path) {
|
|
2500
|
+
const specId = specIdOfPath2(path) ?? previewSpecIdOf(path);
|
|
2501
|
+
if (specId === null) return defaultReader;
|
|
2502
|
+
const branch = specBranchOf(specId);
|
|
2503
|
+
return (await branches()).has(branch) ? readerOfBranch(branch) : defaultReader;
|
|
2504
|
+
}
|
|
2505
|
+
const storage = {
|
|
2506
|
+
lastProposal: null,
|
|
2507
|
+
async readFile(path) {
|
|
2508
|
+
return (await readerOf(path)).readFile(path);
|
|
2509
|
+
},
|
|
2510
|
+
async exists(path) {
|
|
2511
|
+
return (await readerOf(path)).exists(path);
|
|
2512
|
+
},
|
|
2513
|
+
async currentRef() {
|
|
2514
|
+
return defaultReader.currentRef();
|
|
2515
|
+
},
|
|
2516
|
+
async listDirectory(path) {
|
|
2517
|
+
const entries = await defaultReader.listDirectory(path);
|
|
2518
|
+
if (path.replace(/\/$/, "") !== "specs") return entries;
|
|
2519
|
+
const known = new Set(entries.map((entry) => entry.path));
|
|
2520
|
+
const extra = [];
|
|
2521
|
+
for (const branch of await branches()) {
|
|
2522
|
+
const specId = branch.slice("spec/".length);
|
|
2523
|
+
const fileName = `${specId}.spec.md`;
|
|
2524
|
+
if (known.has(fileName)) continue;
|
|
2525
|
+
const reader = readerOfBranch(branch);
|
|
2526
|
+
const specPath = `specs/${fileName}`;
|
|
2527
|
+
if (!await reader.exists(specPath)) continue;
|
|
2528
|
+
const content = await reader.readFile(specPath);
|
|
2529
|
+
extra.push({ path: fileName, sizeBytes: Buffer.byteLength(content, "utf8") });
|
|
2530
|
+
}
|
|
2531
|
+
return [...entries, ...extra].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2532
|
+
},
|
|
2533
|
+
async write(path, content, message) {
|
|
2534
|
+
const specId = specIdOfPath2(path);
|
|
2535
|
+
const startedAt = deps.now();
|
|
2536
|
+
if (specId === null) {
|
|
2537
|
+
requireRole(deps.user, "operator", `write ${path}`);
|
|
2538
|
+
} else if (message.startsWith("spec: validate ")) {
|
|
2539
|
+
requireRole(deps.user, "approver", `validate ${specId}`);
|
|
2540
|
+
} else {
|
|
2541
|
+
requireRole(deps.user, "author", `write ${path}`);
|
|
2542
|
+
}
|
|
2543
|
+
const branch = specId === null ? `host/${deps.repoId}/${slug(path)}` : specBranchOf(specId);
|
|
2544
|
+
let outcome = "ok";
|
|
2545
|
+
let pullRequestUrl = null;
|
|
2546
|
+
try {
|
|
2547
|
+
const proposal = await proposeChange(
|
|
2548
|
+
{ id: deps.repoId, github: deps.github, adapter: storage },
|
|
2549
|
+
{
|
|
2550
|
+
clientFor: () => deps.client,
|
|
2551
|
+
originOf: (proposedPath) => `Propos\xE9 via le studio Ogygie h\xE9berg\xE9 par ${deps.user.displayName} <${deps.user.email}> (repo \`${deps.repoId}\`, chemin \`${proposedPath}\`).`,
|
|
2552
|
+
authorOf: () => ({ name: deps.user.displayName, email: deps.user.email }),
|
|
2553
|
+
branchFor: () => branch,
|
|
2554
|
+
readFrom: "branch",
|
|
2555
|
+
now: deps.now,
|
|
2556
|
+
rateLimitPerHour: deps.rateLimitPerHour,
|
|
2557
|
+
recentProposals: deps.recentProposals
|
|
2558
|
+
},
|
|
2559
|
+
{ path, content, message }
|
|
2560
|
+
);
|
|
2561
|
+
pullRequestUrl = proposal.pullRequestUrl;
|
|
2562
|
+
storage.lastProposal = proposal;
|
|
2563
|
+
(await branches()).add(proposal.branch);
|
|
2564
|
+
branchReaders.delete(proposal.branch);
|
|
2565
|
+
} catch (thrown) {
|
|
2566
|
+
outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
|
|
2567
|
+
throw thrown;
|
|
2568
|
+
} finally {
|
|
2569
|
+
deps.log(
|
|
2570
|
+
JSON.stringify({
|
|
2571
|
+
action: message.split(" ")[1] ?? "write",
|
|
2572
|
+
specId,
|
|
2573
|
+
actor: deps.user.email,
|
|
2574
|
+
outcome,
|
|
2575
|
+
pullRequestUrl,
|
|
2576
|
+
durationMs: deps.now() - startedAt
|
|
2577
|
+
})
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
};
|
|
2582
|
+
return storage;
|
|
2583
|
+
}
|
|
2584
|
+
function createGithubSpecBranchLister(input) {
|
|
2585
|
+
const apiBase = input.apiBase ?? "https://api.github.com";
|
|
2586
|
+
return async () => {
|
|
2587
|
+
const response = await input.fetchImpl(
|
|
2588
|
+
`${apiBase}/repos/${input.github.owner}/${input.github.repo}/git/matching-refs/heads/spec/`,
|
|
2589
|
+
{
|
|
2590
|
+
method: "GET",
|
|
2591
|
+
headers: {
|
|
2592
|
+
accept: "application/vnd.github+json",
|
|
2593
|
+
"x-github-api-version": "2022-11-28",
|
|
2594
|
+
"user-agent": "ogygie-studio"
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
);
|
|
2598
|
+
if (!response.ok) {
|
|
2599
|
+
return throwOgygieError({
|
|
2600
|
+
code: "GITHUB_UNAVAILABLE",
|
|
2601
|
+
message: `GitHub answered ${response.status} when listing spec/* branches.`,
|
|
2602
|
+
file: null,
|
|
2603
|
+
cause: `status=${response.status}`,
|
|
2604
|
+
suggestedFix: "Retry later; check the app installation grants contents on this repo."
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
const refs = await response.json();
|
|
2608
|
+
return refs.map((entry) => entry.ref.replace(/^refs\/heads\//, "")).filter((name) => name.startsWith("spec/"));
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// src/server/hosted/merge.ts
|
|
2613
|
+
var NEEDS_OPERATOR_LABEL = "needs-operator";
|
|
2614
|
+
function requiredRoleFor(mergePolicy) {
|
|
2615
|
+
return mergePolicy === "client" ? "approver" : "operator";
|
|
2616
|
+
}
|
|
2617
|
+
async function specStatusOf(storage, specId) {
|
|
2618
|
+
const path = `specs/${specId}.spec.md`;
|
|
2619
|
+
if (!await storage.exists(path)) return "absent";
|
|
2620
|
+
const parsed = parseSpecDocument(await storage.readFile(path));
|
|
2621
|
+
return parsed.frontmatter?.status ?? "unreadable";
|
|
2622
|
+
}
|
|
2623
|
+
async function pullRequestStatus(deps, specId) {
|
|
2624
|
+
const branch = specBranchOf(specId);
|
|
2625
|
+
const requiredRole = requiredRoleFor(deps.mergePolicy);
|
|
2626
|
+
const [specStatus, head] = await Promise.all([specStatusOf(deps.storage, specId), deps.pulls.findOpenPullRequest(branch)]);
|
|
2627
|
+
if (head === null) {
|
|
2628
|
+
return { specId, branch, pullRequest: null, specStatus, checks: "none", checkRuns: [], mergeable: null, requiredRole, canMerge: false, blockers: ["Aucune pull request ouverte pour cette spec : une sauvegarde en ouvre une."], labels: [], needsOperator: false };
|
|
2629
|
+
}
|
|
2630
|
+
const [pull, checks] = await Promise.all([deps.pulls.getPullRequest(head.number), deps.pulls.getChecks(head.headSha)]);
|
|
2631
|
+
const needsOperator = head.labels.includes(NEEDS_OPERATOR_LABEL);
|
|
2632
|
+
const blockers = [];
|
|
2633
|
+
if (needsOperator) blockers.push("En attente de l'op\xE9rateur : le premier schemas.ts de cette feature nouvelle a \xE9t\xE9 refus\xE9 deux fois par les gates (\xE9tiquette needs-operator).");
|
|
2634
|
+
if (specStatus !== "validated") blockers.push(specStatus === "draft" ? "La spec est encore en draft : elle doit \xEAtre valid\xE9e (sign\xE9e) d'abord." : `La spec est ${specStatus} sur la branche.`);
|
|
2635
|
+
if (checks.state === "red") blockers.push("Un check de la CI est rouge sur la branche.");
|
|
2636
|
+
if (checks.state === "pending") blockers.push("La CI tourne encore sur la branche.");
|
|
2637
|
+
if (checks.state === "none") blockers.push("Aucun check n'a encore couru sur la branche.");
|
|
2638
|
+
if (pull.mergeable === false) blockers.push("La branche est en conflit avec la branche par d\xE9faut : \xE0 r\xE9g\xE9n\xE9rer (op\xE9rateur).");
|
|
2639
|
+
if (pull.mergeable === null) blockers.push("GitHub calcule encore si la branche est fusionnable.");
|
|
2640
|
+
return {
|
|
2641
|
+
specId,
|
|
2642
|
+
branch,
|
|
2643
|
+
pullRequest: { number: pull.number, url: pull.url, headSha: pull.headSha, title: head.title },
|
|
2644
|
+
specStatus,
|
|
2645
|
+
checks: checks.state,
|
|
2646
|
+
checkRuns: checks.runs,
|
|
2647
|
+
mergeable: pull.mergeable,
|
|
2648
|
+
requiredRole,
|
|
2649
|
+
canMerge: blockers.length === 0,
|
|
2650
|
+
blockers,
|
|
2651
|
+
labels: head.labels,
|
|
2652
|
+
needsOperator
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
async function listSpecPullRequests(pulls) {
|
|
2656
|
+
const heads = await pulls.listOpenSpecPullRequests();
|
|
2657
|
+
return Promise.all(
|
|
2658
|
+
heads.map(async (head) => {
|
|
2659
|
+
const checks = await pulls.getChecks(head.headSha);
|
|
2660
|
+
return { specId: head.branch.slice("spec/".length), number: head.number, url: head.url, checks: checks.state, needsOperator: head.labels.includes(NEEDS_OPERATOR_LABEL) };
|
|
2661
|
+
})
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
async function mergeSpecPullRequest(deps, user, specId) {
|
|
2665
|
+
const requiredRole = requiredRoleFor(deps.mergePolicy);
|
|
2666
|
+
requireRole(user, requiredRole, `merge ${specId}`);
|
|
2667
|
+
const status = await pullRequestStatus(deps, specId);
|
|
2668
|
+
const startedAt = deps.now();
|
|
2669
|
+
const refuse2 = (code, message, httpStatus) => throwOgygieError(
|
|
2670
|
+
{ code, message, file: `specs/${specId}.spec.md`, cause: status.blockers.join(" ; "), suggestedFix: "Lift the listed blockers, then merge again \u2014 the rule is the same one the spec-signed check and the branch protection hold.", details: { blockers: status.blockers } },
|
|
2671
|
+
httpStatus
|
|
2672
|
+
);
|
|
2673
|
+
if (status.pullRequest === null) refuse2("PULL_REQUEST_NOT_FOUND", `No open pull request carries spec "${specId}".`, 404);
|
|
2674
|
+
if (status.needsOperator) refuse2("SPEC_NEEDS_OPERATOR", `Spec "${specId}" awaits the operator: its first schemas module failed the gates twice (label ${NEEDS_OPERATOR_LABEL}).`, 409);
|
|
2675
|
+
if (status.specStatus !== "validated") refuse2("SPEC_NOT_VALIDATED", `Spec "${specId}" is not validated: nothing merges before the signature.`, 409);
|
|
2676
|
+
if (status.checks !== "green") refuse2("CHECKS_NOT_GREEN", `The checks of spec "${specId}" are ${status.checks}.`, 409);
|
|
2677
|
+
if (status.mergeable !== true) refuse2("PULL_REQUEST_CONFLICTING", `The branch of spec "${specId}" is not mergeable.`, 409);
|
|
2678
|
+
const pull = status.pullRequest;
|
|
2679
|
+
if (pull === null) return refuse2("PULL_REQUEST_NOT_FOUND", "unreachable", 404);
|
|
2680
|
+
let outcome = "ok";
|
|
2681
|
+
let mergeSha = "";
|
|
2682
|
+
try {
|
|
2683
|
+
const merged = await deps.pulls.mergePullRequest(pull.number, {
|
|
2684
|
+
title: `spec: merge ${specId} \u2014 approuv\xE9e par ${user.displayName}`,
|
|
2685
|
+
message: `Spec ${specId} fusionn\xE9e depuis le studio h\xE9berg\xE9 par ${user.displayName} <${user.email}> (pull request #${pull.number}).`
|
|
2686
|
+
});
|
|
2687
|
+
mergeSha = merged.sha;
|
|
2688
|
+
await deps.pulls.deleteBranch(status.branch);
|
|
2689
|
+
} catch (thrown) {
|
|
2690
|
+
outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
|
|
2691
|
+
throw thrown;
|
|
2692
|
+
} finally {
|
|
2693
|
+
deps.log(JSON.stringify({ action: "merge", specId, actor: user.email, outcome, pullRequestUrl: pull.url, durationMs: deps.now() - startedAt }));
|
|
2694
|
+
}
|
|
2695
|
+
return { specId, pullRequestNumber: pull.number, mergeSha, mergedBy: user.email, mergedAt: new Date(deps.now()).toISOString() };
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// src/server/hosted/merge-routes.ts
|
|
2699
|
+
async function registerMergeRoutes(app, deps) {
|
|
2700
|
+
const route = app.withTypeProvider();
|
|
2701
|
+
const mergeDepsFor = (user) => ({
|
|
2702
|
+
pulls: deps.pulls,
|
|
2703
|
+
storage: deps.storageFor(user),
|
|
2704
|
+
mergePolicy: deps.config.mergePolicy,
|
|
2705
|
+
now: deps.now,
|
|
2706
|
+
log: deps.log
|
|
2707
|
+
});
|
|
2708
|
+
route.route({
|
|
2709
|
+
method: "GET",
|
|
2710
|
+
url: "/api/specs/:specId/pull-request",
|
|
2711
|
+
schema: { params: SpecParamsSchema, response: { 200: PullRequestStatusSchema } },
|
|
2712
|
+
handler: async (request, reply) => {
|
|
2713
|
+
const user = hostedUserOf(request);
|
|
2714
|
+
return reply.code(200).send(await pullRequestStatus(mergeDepsFor(user), request.params.specId));
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
route.route({
|
|
2718
|
+
method: "GET",
|
|
2719
|
+
url: "/api/pull-requests",
|
|
2720
|
+
schema: { response: { 200: SpecPullRequestsSchema } },
|
|
2721
|
+
handler: async (request, reply) => {
|
|
2722
|
+
hostedUserOf(request);
|
|
2723
|
+
return reply.code(200).send({ pullRequests: await listSpecPullRequests(deps.pulls) });
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
route.route({
|
|
2727
|
+
method: "POST",
|
|
2728
|
+
url: "/api/specs/:specId/merge",
|
|
2729
|
+
schema: { params: SpecParamsSchema, response: { 200: MergeOutputSchema } },
|
|
2730
|
+
handler: async (request, reply) => {
|
|
2731
|
+
const user = hostedUserOf(request);
|
|
2732
|
+
return reply.code(200).send(await mergeSpecPullRequest(mergeDepsFor(user), user, request.params.specId));
|
|
2733
|
+
}
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// src/server/hosted/promotion.ts
|
|
2738
|
+
var ENVIRONMENT_REFS = {
|
|
2739
|
+
staging: "env/staging",
|
|
2740
|
+
production: "env/production"
|
|
2741
|
+
};
|
|
2742
|
+
var SHA = /^[0-9a-f]{40}$/;
|
|
2743
|
+
var PULL_NUMBER = /\(#(\d+)\)/;
|
|
2744
|
+
var MERGED_SPEC = /^spec: merge ([a-z][a-z0-9]*(?:-[a-z0-9]+)*)/;
|
|
2745
|
+
var DESTRUCTIVE_MIGRATION = /migration destructive/i;
|
|
2746
|
+
async function environmentsStatus(deps) {
|
|
2747
|
+
const [mainSha, stagingSha, productionSha, stagingDeployment, productionDeployment, releases] = await Promise.all([
|
|
2748
|
+
deps.deploy.getRefSha(deps.defaultBranch),
|
|
2749
|
+
deps.deploy.getRefSha(ENVIRONMENT_REFS.staging),
|
|
2750
|
+
deps.deploy.getRefSha(ENVIRONMENT_REFS.production),
|
|
2751
|
+
deps.deploy.latestDeployment("staging"),
|
|
2752
|
+
deps.deploy.latestDeployment("production"),
|
|
2753
|
+
deps.deploy.listTags("release/")
|
|
2754
|
+
]);
|
|
2755
|
+
const deploymentOf = (deployment) => deployment === null ? null : { id: deployment.id, state: deployment.state, createdAt: deployment.createdAt };
|
|
2756
|
+
return {
|
|
2757
|
+
environments: [
|
|
2758
|
+
{ name: "development", ref: deps.defaultBranch, sha: mainSha, deployment: null },
|
|
2759
|
+
{ name: "staging", ref: ENVIRONMENT_REFS.staging, sha: stagingSha, deployment: deploymentOf(stagingDeployment) },
|
|
2760
|
+
{ name: "production", ref: ENVIRONMENT_REFS.production, sha: productionSha, deployment: deploymentOf(productionDeployment) }
|
|
2761
|
+
],
|
|
2762
|
+
releases: releases.sort().reverse()
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
function pullRequestsOf(commits) {
|
|
2766
|
+
const included = [];
|
|
2767
|
+
for (const commit of commits) {
|
|
2768
|
+
const firstLine = commit.message.split("\n")[0] ?? "";
|
|
2769
|
+
const number = PULL_NUMBER.exec(firstLine)?.[1];
|
|
2770
|
+
if (number === void 0) continue;
|
|
2771
|
+
included.push({
|
|
2772
|
+
number: Number.parseInt(number, 10),
|
|
2773
|
+
specId: MERGED_SPEC.exec(firstLine)?.[1] ?? null,
|
|
2774
|
+
message: firstLine,
|
|
2775
|
+
authoredAt: commit.authoredAt
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
return included;
|
|
2779
|
+
}
|
|
2780
|
+
function nextReleaseTag(existing, nowMs) {
|
|
2781
|
+
const day = new Date(nowMs).toISOString().slice(0, 10);
|
|
2782
|
+
const prefix = `release/${day}.`;
|
|
2783
|
+
const taken = existing.filter((tag) => tag.startsWith(prefix)).length;
|
|
2784
|
+
return `${prefix}${taken + 1}`;
|
|
2785
|
+
}
|
|
2786
|
+
function refuse(code, message, cause, suggestedFix, httpStatus = 409) {
|
|
2787
|
+
return throwOgygieError({ code, message, file: null, cause, suggestedFix }, httpStatus);
|
|
2788
|
+
}
|
|
2789
|
+
async function promote(deps, user, input) {
|
|
2790
|
+
const rollback = input.rollback === true;
|
|
2791
|
+
requireRole(user, rollback ? "operator" : "approver", `promote ${input.environment}`);
|
|
2792
|
+
if (!SHA.test(input.sha)) {
|
|
2793
|
+
refuse("CONTRACT_VALIDATION_FAILED", "A promotion names a full commit sha.", `sha=${input.sha}`, "Give the 40-character sha shown on the environments screen.", 400);
|
|
2794
|
+
}
|
|
2795
|
+
if (rollback && (input.reason === void 0 || input.reason.trim().length < 10)) {
|
|
2796
|
+
refuse("CONTRACT_VALIDATION_FAILED", "A rollback carries its reason.", "rollback without reason", "Say why the environment goes back (ten characters at least).", 400);
|
|
2797
|
+
}
|
|
2798
|
+
const ref = ENVIRONMENT_REFS[input.environment];
|
|
2799
|
+
const [currentSha, targetSha, mainSha] = await Promise.all([
|
|
2800
|
+
deps.deploy.getRefSha(ref),
|
|
2801
|
+
deps.deploy.getCommitSha(input.sha),
|
|
2802
|
+
deps.deploy.getRefSha(deps.defaultBranch)
|
|
2803
|
+
]);
|
|
2804
|
+
if (currentSha === null) {
|
|
2805
|
+
refuse("HOST_ENVIRONMENT_REF_MISSING", `The ref ${ref} does not exist on the repo.`, `ref=${ref}`, "Create and protect env/staging and env/production (docs/HOSTED-STUDIO.md), then retry.", 500);
|
|
2806
|
+
}
|
|
2807
|
+
if (targetSha === null) {
|
|
2808
|
+
refuse("CONTRACT_VALIDATION_FAILED", `Commit ${input.sha} does not exist on the repo.`, `sha=${input.sha}`, "Promote a sha the environments screen shows.", 400);
|
|
2809
|
+
}
|
|
2810
|
+
if (input.environment === "staging") {
|
|
2811
|
+
const inMain = mainSha === null ? "diverged" : (await deps.deploy.compare(targetSha, mainSha)).status;
|
|
2812
|
+
if (inMain !== "ahead" && inMain !== "identical") {
|
|
2813
|
+
refuse("PROMOTION_ORDER_VIOLATED", `Commit ${targetSha.slice(0, 7)} is not in the history of ${deps.defaultBranch}.`, `compare(${targetSha.slice(0, 7)}...${deps.defaultBranch})=${inMain}`, "Staging only receives what the default branch has merged.");
|
|
2814
|
+
}
|
|
2815
|
+
} else {
|
|
2816
|
+
const stagingSha = await deps.deploy.getRefSha(ENVIRONMENT_REFS.staging);
|
|
2817
|
+
if (stagingSha !== targetSha) {
|
|
2818
|
+
refuse("PROMOTION_ORDER_VIOLATED", `Production only receives the sha staging holds (${stagingSha?.slice(0, 7) ?? "none"}), not ${targetSha.slice(0, 7)}.`, `staging=${stagingSha ?? "<absent>"} asked=${targetSha}`, "Promote that sha to staging first, see it there, then to production \u2014 the order is the rule, to the sha.");
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
const forward = await deps.deploy.compare(currentSha, targetSha);
|
|
2822
|
+
if (forward.status === "identical") {
|
|
2823
|
+
refuse("PROMOTION_ORDER_VIOLATED", `${input.environment} already holds ${targetSha.slice(0, 7)}.`, "nothing to move", "Pick a newer sha, or nothing.");
|
|
2824
|
+
}
|
|
2825
|
+
if (forward.status !== "ahead" && !rollback) {
|
|
2826
|
+
refuse("PROMOTION_NOT_FAST_FORWARD", `Moving ${input.environment} to ${targetSha.slice(0, 7)} is not a fast-forward (${forward.status}).`, `compare(${currentSha.slice(0, 7)}...${targetSha.slice(0, 7)})=${forward.status}`, "A step back is an explicit rollback, reserved to the operator, with its reason.");
|
|
2827
|
+
}
|
|
2828
|
+
const includedPullRequests = rollback ? [] : pullRequestsOf(forward.commits);
|
|
2829
|
+
let destructiveMigration = false;
|
|
2830
|
+
for (const pull of includedPullRequests) {
|
|
2831
|
+
if (DESTRUCTIVE_MIGRATION.test(await deps.deploy.getPullRequestBody(pull.number))) destructiveMigration = true;
|
|
2832
|
+
}
|
|
2833
|
+
if (destructiveMigration && input.environment === "production") {
|
|
2834
|
+
requireRole(user, "operator", `promote to production a release with a destructive migration`);
|
|
2835
|
+
}
|
|
2836
|
+
const startedAt = deps.now();
|
|
2837
|
+
let outcome = "ok";
|
|
2838
|
+
let deploymentId = 0;
|
|
2839
|
+
let releaseTag = null;
|
|
2840
|
+
try {
|
|
2841
|
+
await deps.deploy.updateRef(ref, targetSha, rollback);
|
|
2842
|
+
const description = rollback ? `Retour arri\xE8re de ${input.environment} par ${user.displayName} : ${input.reason ?? ""}` : `Promotion de ${input.environment} par ${user.displayName} (${includedPullRequests.length} PR)`;
|
|
2843
|
+
deploymentId = (await deps.deploy.createDeployment({
|
|
2844
|
+
sha: targetSha,
|
|
2845
|
+
environment: input.environment,
|
|
2846
|
+
description: description.slice(0, 140),
|
|
2847
|
+
payload: { actor: user.email, fromSha: currentSha, rollback, reason: input.reason ?? null, includedPullRequests, destructiveMigration }
|
|
2848
|
+
})).id;
|
|
2849
|
+
if (input.environment === "production" && !rollback) {
|
|
2850
|
+
releaseTag = nextReleaseTag(await deps.deploy.listTags("release/"), deps.now());
|
|
2851
|
+
const lines = includedPullRequests.map((pull) => `- #${pull.number} ${pull.specId ?? ""} ${pull.authoredAt.slice(0, 10)} \u2014 ${pull.message}`.replace(/\s+/g, " "));
|
|
2852
|
+
await deps.deploy.createTag({
|
|
2853
|
+
name: releaseTag,
|
|
2854
|
+
sha: targetSha,
|
|
2855
|
+
message: `Release ${releaseTag} \u2014 mise en production par ${user.displayName}
|
|
2856
|
+
|
|
2857
|
+
${lines.join("\n") || "(aucune pull request list\xE9e)"}
|
|
2858
|
+
`,
|
|
2859
|
+
tagger: { name: user.displayName, email: user.email }
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
} catch (thrown) {
|
|
2863
|
+
outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
|
|
2864
|
+
throw thrown;
|
|
2865
|
+
} finally {
|
|
2866
|
+
deps.log(JSON.stringify({ action: rollback ? "rollback" : "promote", environment: input.environment, actor: user.email, outcome, fromSha: currentSha, toSha: targetSha, releaseTag, durationMs: deps.now() - startedAt }));
|
|
2867
|
+
}
|
|
2868
|
+
return {
|
|
2869
|
+
environment: input.environment,
|
|
2870
|
+
fromSha: currentSha,
|
|
2871
|
+
toSha: targetSha,
|
|
2872
|
+
actor: user.email,
|
|
2873
|
+
at: new Date(deps.now()).toISOString(),
|
|
2874
|
+
deploymentId,
|
|
2875
|
+
releaseTag,
|
|
2876
|
+
includedPullRequests,
|
|
2877
|
+
destructiveMigration,
|
|
2878
|
+
rollback
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/server/hosted/promotion-routes.ts
|
|
2883
|
+
async function registerPromotionRoutes(app, deps) {
|
|
2884
|
+
const route = app.withTypeProvider();
|
|
2885
|
+
const promotionDeps = { deploy: deps.deploy, defaultBranch: deps.defaultBranch, now: deps.now, log: deps.log };
|
|
2886
|
+
route.route({
|
|
2887
|
+
method: "GET",
|
|
2888
|
+
url: "/api/environments",
|
|
2889
|
+
schema: { response: { 200: EnvironmentsStatusSchema } },
|
|
2890
|
+
handler: async (request, reply) => {
|
|
2891
|
+
hostedUserOf(request);
|
|
2892
|
+
return reply.code(200).send(await environmentsStatus(promotionDeps));
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
route.route({
|
|
2896
|
+
method: "POST",
|
|
2897
|
+
url: "/api/environments/:environment/promotions",
|
|
2898
|
+
schema: { params: EnvironmentParamsSchema, body: PromotionInputSchema, response: { 201: PromotionRecordSchema } },
|
|
2899
|
+
handler: async (request, reply) => {
|
|
2900
|
+
const user = hostedUserOf(request);
|
|
2901
|
+
const record = await promote(promotionDeps, user, { environment: request.params.environment, ...request.body });
|
|
2902
|
+
return reply.code(201).send(record);
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/server/hosted/usage.ts
|
|
2908
|
+
import { z as z10 } from "zod";
|
|
2909
|
+
var UsageEntrySchema = z10.object({
|
|
2910
|
+
at: z10.string(),
|
|
2911
|
+
/** brief → spec from the instance; `speculate` entries arrive with the generator's lot. */
|
|
2912
|
+
action: z10.enum(["brief", "speculate"]),
|
|
2913
|
+
attempt: z10.number().int().positive().optional(),
|
|
2914
|
+
model: z10.string(),
|
|
2915
|
+
inputTokens: z10.number().int().nonnegative(),
|
|
2916
|
+
outputTokens: z10.number().int().nonnegative(),
|
|
2917
|
+
actor: z10.string().optional(),
|
|
2918
|
+
specId: z10.string().optional()
|
|
2919
|
+
});
|
|
2920
|
+
var USAGE_MARKER = "<!-- ogygie-usage -->";
|
|
2921
|
+
var DEFAULT_MODEL_COSTS = {
|
|
2922
|
+
"claude-fable-5-1": { input: 10, output: 50 },
|
|
2923
|
+
"claude-opus-5-5": { input: 4, output: 20 },
|
|
2924
|
+
"claude-sonnet-5-5": { input: 2, output: 10 },
|
|
2925
|
+
"claude-haiku-5-5": { input: 0.1, output: 0.5 }
|
|
2926
|
+
};
|
|
2927
|
+
function estimateUsd(entry, costs = DEFAULT_MODEL_COSTS) {
|
|
2928
|
+
const cost = costs[entry.model];
|
|
2929
|
+
if (cost === void 0) return null;
|
|
2930
|
+
return (entry.inputTokens * cost.input + entry.outputTokens * cost.output) / 1e6;
|
|
2931
|
+
}
|
|
2932
|
+
function formatUsageComment(entries) {
|
|
2933
|
+
const rows = entries.map((entry) => `| ${entry.at.slice(0, 16).replace("T", " ")} | ${entry.action}${entry.attempt === void 0 ? "" : ` ${entry.attempt}`} | ${entry.model} | ${entry.inputTokens} | ${entry.outputTokens} |`);
|
|
2934
|
+
return [
|
|
2935
|
+
USAGE_MARKER,
|
|
2936
|
+
"**Consommation de mod\xE8les (journal du studio h\xE9berg\xE9)** \u2014 estimation, la facture vient de l'espace de travail.",
|
|
2937
|
+
"",
|
|
2938
|
+
"| quand (UTC) | action | mod\xE8le | entr\xE9e | sortie |",
|
|
2939
|
+
"|---|---|---|---|---|",
|
|
2940
|
+
...rows,
|
|
2941
|
+
"",
|
|
2942
|
+
"```json",
|
|
2943
|
+
JSON.stringify(entries),
|
|
2944
|
+
"```",
|
|
2945
|
+
""
|
|
2946
|
+
].join("\n");
|
|
2947
|
+
}
|
|
2948
|
+
function parseUsageComment(body) {
|
|
2949
|
+
if (!body.includes(USAGE_MARKER)) return [];
|
|
2950
|
+
const block = /```json\n([\s\S]*?)\n```/.exec(body)?.[1];
|
|
2951
|
+
if (block === void 0) return [];
|
|
2952
|
+
try {
|
|
2953
|
+
const parsed = z10.array(UsageEntrySchema).safeParse(JSON.parse(block));
|
|
2954
|
+
return parsed.success ? parsed.data : [];
|
|
2955
|
+
} catch {
|
|
2956
|
+
return [];
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
function monthBounds(month) {
|
|
2960
|
+
const [year, monthIndex] = month.split("-").map((part) => Number.parseInt(part, 10));
|
|
2961
|
+
const start = new Date(Date.UTC(year ?? 1970, (monthIndex ?? 1) - 1, 1));
|
|
2962
|
+
const end = new Date(Date.UTC(year ?? 1970, monthIndex ?? 1, 1));
|
|
2963
|
+
return { start: start.toISOString(), end: end.toISOString() };
|
|
2964
|
+
}
|
|
2965
|
+
async function consumptionReport(input) {
|
|
2966
|
+
const { start, end } = monthBounds(input.month);
|
|
2967
|
+
const costs = input.costs ?? DEFAULT_MODEL_COSTS;
|
|
2968
|
+
const journals = await input.source.journalsSince(start);
|
|
2969
|
+
const specs = [];
|
|
2970
|
+
for (const journal of journals) {
|
|
2971
|
+
const inMonth = journal.entries.filter((entry) => entry.at >= start && entry.at < end);
|
|
2972
|
+
if (inMonth.length === 0) continue;
|
|
2973
|
+
const uncosted = /* @__PURE__ */ new Set();
|
|
2974
|
+
let estimatedUsd = 0;
|
|
2975
|
+
for (const entry of inMonth) {
|
|
2976
|
+
const usd = estimateUsd(entry, costs);
|
|
2977
|
+
if (usd === null) uncosted.add(entry.model);
|
|
2978
|
+
else estimatedUsd += usd;
|
|
2979
|
+
}
|
|
2980
|
+
specs.push({
|
|
2981
|
+
specId: journal.specId,
|
|
2982
|
+
pullRequestNumber: journal.pullRequestNumber,
|
|
2983
|
+
runs: inMonth.length,
|
|
2984
|
+
inputTokens: inMonth.reduce((sum, entry) => sum + entry.inputTokens, 0),
|
|
2985
|
+
outputTokens: inMonth.reduce((sum, entry) => sum + entry.outputTokens, 0),
|
|
2986
|
+
estimatedUsd: Math.round(estimatedUsd * 1e4) / 1e4,
|
|
2987
|
+
uncostedModels: [...uncosted].sort()
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
specs.sort((a, b) => b.estimatedUsd - a.estimatedUsd || a.specId.localeCompare(b.specId));
|
|
2991
|
+
const totals = {
|
|
2992
|
+
runs: specs.reduce((sum, spec) => sum + spec.runs, 0),
|
|
2993
|
+
inputTokens: specs.reduce((sum, spec) => sum + spec.inputTokens, 0),
|
|
2994
|
+
outputTokens: specs.reduce((sum, spec) => sum + spec.outputTokens, 0),
|
|
2995
|
+
estimatedUsd: Math.round(specs.reduce((sum, spec) => sum + spec.estimatedUsd, 0) * 1e4) / 1e4
|
|
2996
|
+
};
|
|
2997
|
+
const budgetRatio = input.budgetUsd === null ? null : totals.estimatedUsd / input.budgetUsd;
|
|
2998
|
+
return { month: input.month, specs, totals, budgetUsd: input.budgetUsd, budgetRatio, warning: budgetRatio !== null && budgetRatio >= 0.8 };
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// src/server/hosted/consumption-routes.ts
|
|
3002
|
+
async function registerConsumptionRoutes(app, deps) {
|
|
3003
|
+
const route = app.withTypeProvider();
|
|
3004
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3005
|
+
const cacheMs = deps.cacheMs ?? 5 * 60 * 1e3;
|
|
3006
|
+
route.route({
|
|
3007
|
+
method: "GET",
|
|
3008
|
+
url: "/api/consumption",
|
|
3009
|
+
schema: { querystring: ConsumptionQuerySchema, response: { 200: ConsumptionReportSchema } },
|
|
3010
|
+
handler: async (request, reply) => {
|
|
3011
|
+
hostedUserOf(request);
|
|
3012
|
+
const month = request.query.month ?? new Date(deps.now()).toISOString().slice(0, 7);
|
|
3013
|
+
const cached = cache.get(month);
|
|
3014
|
+
if (cached !== void 0 && deps.now() - cached.at < cacheMs) return reply.code(200).send(cached.report);
|
|
3015
|
+
const report = await consumptionReport({
|
|
3016
|
+
source: deps.usage,
|
|
3017
|
+
month,
|
|
3018
|
+
budgetUsd: deps.config.model?.monthlyBudgetUsd ?? null,
|
|
3019
|
+
...deps.config.model?.costPerMillionUsd === void 0 ? {} : { costs: deps.config.model.costPerMillionUsd }
|
|
3020
|
+
});
|
|
3021
|
+
cache.set(month, { at: deps.now(), report });
|
|
3022
|
+
return reply.code(200).send(report);
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// src/server/hosted/preview-routes.ts
|
|
3028
|
+
import { z as z11 } from "zod";
|
|
3029
|
+
var PreviewParamsSchema = SpecParamsSchema.extend({ state: z11.string().min(1).max(80) });
|
|
3030
|
+
var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z11.string().min(1).max(80) });
|
|
3031
|
+
var PREVIEW_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
|
|
3032
|
+
async function registerPreviewRoutes(app, deps) {
|
|
3033
|
+
const route = app.withTypeProvider();
|
|
3034
|
+
route.route({
|
|
3035
|
+
method: "GET",
|
|
3036
|
+
url: "/api/specs/:specId/previews",
|
|
3037
|
+
schema: { params: SpecParamsSchema, response: { 200: PreviewsListSchema } },
|
|
3038
|
+
handler: async (request, reply) => {
|
|
3039
|
+
const user = hostedUserOf(request);
|
|
3040
|
+
const storage = deps.storageFor(user);
|
|
3041
|
+
const { specId } = request.params;
|
|
3042
|
+
const specPath = `specs/${specId}.spec.md`;
|
|
3043
|
+
const composition = await storage.exists(specPath) ? compositionOfSpec(await storage.readFile(specPath)) : null;
|
|
3044
|
+
const compositions = (await listPreviewCompositions(storage, specId)).map((entry) => ({ ...entry, selected: entry.name === composition }));
|
|
3045
|
+
const previews = composition !== null && compositions.some((entry) => entry.name === composition) ? await listPreviews(storage, specId, composition) : await listPreviews(storage, specId);
|
|
3046
|
+
return reply.code(200).send({ specId, previews, composition, compositions });
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
route.route({
|
|
3050
|
+
method: "GET",
|
|
3051
|
+
url: "/api/specs/:specId/previews/:composition/:state",
|
|
3052
|
+
schema: { params: CompositionPreviewParamsSchema },
|
|
3053
|
+
handler: async (request, reply) => {
|
|
3054
|
+
const user = hostedUserOf(request);
|
|
3055
|
+
const html = await readPreview(deps.storageFor(user), request.params.specId, request.params.state, request.params.composition);
|
|
3056
|
+
return reply.code(200).header("content-type", "text/html; charset=utf-8").header("content-security-policy", PREVIEW_CSP).header("x-frame-options", "SAMEORIGIN").header("cache-control", "no-store").send(html);
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
route.route({
|
|
3060
|
+
method: "GET",
|
|
3061
|
+
url: "/api/specs/:specId/previews/:state",
|
|
3062
|
+
schema: { params: PreviewParamsSchema },
|
|
3063
|
+
handler: async (request, reply) => {
|
|
3064
|
+
const user = hostedUserOf(request);
|
|
3065
|
+
const html = await readPreview(deps.storageFor(user), request.params.specId, request.params.state);
|
|
3066
|
+
return reply.code(200).header("content-type", "text/html; charset=utf-8").header("content-security-policy", PREVIEW_CSP).header("x-frame-options", "SAMEORIGIN").header("cache-control", "no-store").send(html);
|
|
3067
|
+
}
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// src/server/hosted/composition-routes.ts
|
|
3072
|
+
import { z as z12 } from "zod";
|
|
3073
|
+
|
|
3074
|
+
// src/server/hosted/compositions.ts
|
|
3075
|
+
var COMPOSITION_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
3076
|
+
async function selectComposition(deps, specId, composition) {
|
|
3077
|
+
if (!COMPOSITION_NAME.test(composition)) {
|
|
3078
|
+
throwOgygieError(
|
|
3079
|
+
{ code: "COMPOSITION_NOT_FOUND", message: `"${composition}" is not a composition name.`, file: null, cause: "a composition is named in kebab-case, as its `# Design` line declares it", suggestedFix: "Pick a composition from GET /api/specs/<id>/previews." },
|
|
3080
|
+
404
|
|
3081
|
+
);
|
|
3082
|
+
}
|
|
3083
|
+
const path = `specs/${specId}.spec.md`;
|
|
3084
|
+
const head = await deps.storage.exists(path) ? await deps.storage.readFile(path) : null;
|
|
3085
|
+
if (head !== null && compositionOfSpec(head) === composition) {
|
|
3086
|
+
return { specId, composition, alreadySelected: true };
|
|
3087
|
+
}
|
|
3088
|
+
const commits = await deps.pulls.listFileCommits(specBranchOf(specId), path);
|
|
3089
|
+
for (const commit of commits) {
|
|
3090
|
+
let content;
|
|
3091
|
+
try {
|
|
3092
|
+
content = await deps.readerFor(commit.sha).readFile(path);
|
|
3093
|
+
} catch {
|
|
3094
|
+
continue;
|
|
3095
|
+
}
|
|
3096
|
+
if (compositionOfSpec(content) !== composition) continue;
|
|
3097
|
+
await deps.storage.write(path, content, `spec: select composition ${composition} for ${specId}
|
|
3098
|
+
|
|
3099
|
+
La composition \xAB ${composition} \xBB est retenue parmi celles propos\xE9es ; les autres restent dans l'historique de la branche, jamais r\xE9\xE9crit.`);
|
|
3100
|
+
deps.log(JSON.stringify({ action: "select-composition", specId, composition, fromSha: commit.sha }));
|
|
3101
|
+
return { specId, composition, alreadySelected: false };
|
|
3102
|
+
}
|
|
3103
|
+
return throwOgygieError(
|
|
3104
|
+
{
|
|
3105
|
+
code: "COMPOSITION_NOT_FOUND",
|
|
3106
|
+
message: `No version of spec "${specId}" on its branch declares the composition "${composition}".`,
|
|
3107
|
+
file: path,
|
|
3108
|
+
cause: `${commits.length} commit(s) of ${path} read on ${specBranchOf(specId)}, none with \`Composition : ${composition}\` under # Design`,
|
|
3109
|
+
suggestedFix: "Choose one of the compositions the previews list names; a composition the model never proposed has no spec to select."
|
|
3110
|
+
},
|
|
3111
|
+
404
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// src/server/hosted/composition-routes.ts
|
|
3116
|
+
var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z12.string().min(1).max(80) });
|
|
3117
|
+
async function registerCompositionRoutes(app, deps) {
|
|
3118
|
+
const route = app.withTypeProvider();
|
|
3119
|
+
route.route({
|
|
3120
|
+
method: "POST",
|
|
3121
|
+
url: "/api/specs/:specId/compositions/:composition/select",
|
|
3122
|
+
schema: { params: CompositionParamsSchema, response: { 200: SelectCompositionOutputSchema } },
|
|
3123
|
+
handler: async (request, reply) => {
|
|
3124
|
+
const user = hostedUserOf(request);
|
|
3125
|
+
requireRole(user, "author", `select a composition of ${request.params.specId}`);
|
|
3126
|
+
const result = await selectComposition(
|
|
3127
|
+
{ storage: deps.storageFor(user), pulls: deps.pulls, readerFor: deps.readerFor, log: deps.log },
|
|
3128
|
+
request.params.specId,
|
|
3129
|
+
request.params.composition
|
|
3130
|
+
);
|
|
3131
|
+
return reply.code(200).send(result);
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// src/server/app.ts
|
|
3137
|
+
async function createStudioApp(options) {
|
|
3138
|
+
const app = Fastify({ logger: false });
|
|
3139
|
+
app.setValidatorCompiler(validatorCompiler);
|
|
3140
|
+
app.setSerializerCompiler(serializerCompiler);
|
|
3141
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
3142
|
+
if (error instanceof OgygieFailure) {
|
|
3143
|
+
return reply.status(error.httpStatus).send(error.error);
|
|
3144
|
+
}
|
|
3145
|
+
if (hasZodFastifySchemaValidationErrors(error)) {
|
|
3146
|
+
return reply.status(400).send(
|
|
3147
|
+
ogygieError({
|
|
3148
|
+
code: "CONTRACT_VALIDATION_FAILED",
|
|
3149
|
+
message: "The request does not satisfy the route's input contract.",
|
|
3150
|
+
file: null,
|
|
3151
|
+
cause: error.validation.map((issue) => `${issue.instancePath || "(root)"}: ${issue.message ?? "invalid"}`).join(" ; "),
|
|
1069
3152
|
suggestedFix: "Fix the listed fields to match the route's Zod input schema."
|
|
1070
3153
|
})
|
|
1071
3154
|
);
|
|
@@ -1081,7 +3164,31 @@ async function createStudioApp(options) {
|
|
|
1081
3164
|
})
|
|
1082
3165
|
);
|
|
1083
3166
|
});
|
|
1084
|
-
|
|
3167
|
+
if (options.hosted !== void 0) {
|
|
3168
|
+
const hosted = options.hosted;
|
|
3169
|
+
await registerHostedRoutes(app, hosted);
|
|
3170
|
+
await registerStudioRoutes(app, (request) => ({ storage: hosted.storageFor(hostedUserOf(request)) }));
|
|
3171
|
+
await registerPreviewRoutes(app, { storageFor: hosted.storageFor });
|
|
3172
|
+
if (hosted.writer !== void 0) {
|
|
3173
|
+
await registerBriefRoutes(app, { writer: hosted.writer, storageFor: hosted.storageFor, log: hosted.log, usage: hosted.usage, now: hosted.now });
|
|
3174
|
+
}
|
|
3175
|
+
if (hosted.pulls !== void 0) {
|
|
3176
|
+
await registerMergeRoutes(app, { pulls: hosted.pulls, storageFor: hosted.storageFor, config: hosted.config, now: hosted.now, log: hosted.log });
|
|
3177
|
+
if (hosted.readerFor !== void 0) {
|
|
3178
|
+
await registerCompositionRoutes(app, { pulls: hosted.pulls, readerFor: hosted.readerFor, storageFor: hosted.storageFor, log: hosted.log });
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
if (hosted.usage !== void 0) {
|
|
3182
|
+
await registerConsumptionRoutes(app, { usage: hosted.usage, config: hosted.config, now: hosted.now });
|
|
3183
|
+
}
|
|
3184
|
+
if (hosted.deploy !== void 0) {
|
|
3185
|
+
await registerPromotionRoutes(app, { deploy: hosted.deploy, defaultBranch: hosted.config.repo.defaultBranch, now: hosted.now, log: hosted.log });
|
|
3186
|
+
}
|
|
3187
|
+
} else if (options.storage !== void 0) {
|
|
3188
|
+
await registerStudioRoutes(app, { storage: options.storage });
|
|
3189
|
+
} else {
|
|
3190
|
+
throw new Error("createStudioApp: either `storage` (local) or `hosted` must be given.");
|
|
3191
|
+
}
|
|
1085
3192
|
if (options.uiDistDir !== void 0) {
|
|
1086
3193
|
const fastifyStatic = (await import("@fastify/static")).default;
|
|
1087
3194
|
await app.register(fastifyStatic, { root: options.uiDistDir });
|
|
@@ -1104,6 +3211,518 @@ async function createStudioApp(options) {
|
|
|
1104
3211
|
return app;
|
|
1105
3212
|
}
|
|
1106
3213
|
|
|
3214
|
+
// src/server/hosted/github-app.ts
|
|
3215
|
+
import { createSign } from "node:crypto";
|
|
3216
|
+
var base64url2 = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3217
|
+
function signGithubAppJwt(input) {
|
|
3218
|
+
const nowSeconds = Math.floor(input.nowMs / 1e3);
|
|
3219
|
+
const header = base64url2(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
3220
|
+
const payload = base64url2(
|
|
3221
|
+
JSON.stringify({ iat: nowSeconds - 60, exp: nowSeconds + 600, iss: input.appId })
|
|
3222
|
+
);
|
|
3223
|
+
const signer = createSign("RSA-SHA256");
|
|
3224
|
+
signer.update(`${header}.${payload}`);
|
|
3225
|
+
const signature = base64url2(signer.sign(input.privateKeyPem));
|
|
3226
|
+
return `${header}.${payload}.${signature}`;
|
|
3227
|
+
}
|
|
3228
|
+
var RENEW_BEFORE_MS = 5 * 60 * 1e3;
|
|
3229
|
+
function createGithubAppAuth(deps) {
|
|
3230
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
3231
|
+
let cached = null;
|
|
3232
|
+
async function mint() {
|
|
3233
|
+
const jwt = signGithubAppJwt({ appId: deps.appId, privateKeyPem: deps.privateKeyPem, nowMs: deps.now() });
|
|
3234
|
+
let response;
|
|
3235
|
+
try {
|
|
3236
|
+
response = await deps.fetchImpl(`${apiBase}/app/installations/${deps.installationId}/access_tokens`, {
|
|
3237
|
+
method: "POST",
|
|
3238
|
+
headers: {
|
|
3239
|
+
authorization: `Bearer ${jwt}`,
|
|
3240
|
+
accept: "application/vnd.github+json",
|
|
3241
|
+
"x-github-api-version": "2022-11-28",
|
|
3242
|
+
"user-agent": "ogygie-studio"
|
|
3243
|
+
}
|
|
3244
|
+
});
|
|
3245
|
+
} catch (thrown) {
|
|
3246
|
+
return throwOgygieError({
|
|
3247
|
+
code: "GITHUB_APP_AUTH_FAILED",
|
|
3248
|
+
message: "GitHub could not be reached to mint an installation token.",
|
|
3249
|
+
file: null,
|
|
3250
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3251
|
+
suggestedFix: "Retry once the network is back; nothing was written."
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
if (!response.ok) {
|
|
3255
|
+
return throwOgygieError({
|
|
3256
|
+
code: "GITHUB_APP_AUTH_FAILED",
|
|
3257
|
+
message: `GitHub answered ${response.status} when minting the installation token.`,
|
|
3258
|
+
file: null,
|
|
3259
|
+
cause: `status=${response.status} for installation ${deps.installationId}`,
|
|
3260
|
+
suggestedFix: "Check githubApp.appId, githubApp.installationId and the private key: the app must be installed on this repo with contents and pull requests permissions."
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
3263
|
+
const body = await response.json();
|
|
3264
|
+
return { token: body.token, expiresAtMs: Date.parse(body.expires_at) };
|
|
3265
|
+
}
|
|
3266
|
+
async function installationToken() {
|
|
3267
|
+
if (cached === null || cached.expiresAtMs - deps.now() < RENEW_BEFORE_MS) {
|
|
3268
|
+
cached = await mint();
|
|
3269
|
+
}
|
|
3270
|
+
return cached.token;
|
|
3271
|
+
}
|
|
3272
|
+
const installationFetch = async (input, init) => {
|
|
3273
|
+
const token = await installationToken();
|
|
3274
|
+
const headers = new Headers(init?.headers);
|
|
3275
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
3276
|
+
return deps.fetchImpl(input, { ...init, headers });
|
|
3277
|
+
};
|
|
3278
|
+
return { installationToken, installationFetch };
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
// src/server/hosted/mail.ts
|
|
3282
|
+
function createHttpMailSender(deps) {
|
|
3283
|
+
return {
|
|
3284
|
+
async send(input) {
|
|
3285
|
+
let response;
|
|
3286
|
+
try {
|
|
3287
|
+
response = await deps.fetchImpl(deps.providerUrl, {
|
|
3288
|
+
method: "POST",
|
|
3289
|
+
headers: { authorization: `Bearer ${deps.apiKey}`, "content-type": "application/json" },
|
|
3290
|
+
body: JSON.stringify({ from: deps.fromAddress, to: input.to, subject: input.subject, text: input.text })
|
|
3291
|
+
});
|
|
3292
|
+
} catch (thrown) {
|
|
3293
|
+
return throwOgygieError({
|
|
3294
|
+
code: "MAIL_PROVIDER_UNAVAILABLE",
|
|
3295
|
+
message: "The mail provider could not be reached.",
|
|
3296
|
+
file: null,
|
|
3297
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3298
|
+
suggestedFix: "Check mail.providerUrl and the network; the sign-in link was not sent."
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
if (!response.ok) {
|
|
3302
|
+
return throwOgygieError({
|
|
3303
|
+
code: "MAIL_PROVIDER_UNAVAILABLE",
|
|
3304
|
+
message: `The mail provider answered ${response.status}.`,
|
|
3305
|
+
file: null,
|
|
3306
|
+
cause: `status=${response.status}`,
|
|
3307
|
+
suggestedFix: "Check the provider key (mail.apiKeyPath) and the sender address (mail.fromAddress)."
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// ../ogygie-core/src/model.ts
|
|
3315
|
+
function createMessagesClient(deps) {
|
|
3316
|
+
const apiBase = deps.apiBase ?? "https://api.anthropic.com";
|
|
3317
|
+
const credential = deps.credentialHint ?? "the model API key";
|
|
3318
|
+
return {
|
|
3319
|
+
async complete(input) {
|
|
3320
|
+
let response;
|
|
3321
|
+
try {
|
|
3322
|
+
response = await deps.fetchImpl(`${apiBase}/v1/messages`, {
|
|
3323
|
+
method: "POST",
|
|
3324
|
+
headers: {
|
|
3325
|
+
"x-api-key": deps.apiKey,
|
|
3326
|
+
"anthropic-version": "2023-06-01",
|
|
3327
|
+
"content-type": "application/json"
|
|
3328
|
+
},
|
|
3329
|
+
body: JSON.stringify({
|
|
3330
|
+
model: input.model,
|
|
3331
|
+
max_tokens: input.maxTokens ?? 16e3,
|
|
3332
|
+
system: [{ type: "text", text: input.system, cache_control: { type: "ephemeral" } }],
|
|
3333
|
+
messages: input.messages,
|
|
3334
|
+
output_config: { effort: input.effort ?? "high" }
|
|
3335
|
+
})
|
|
3336
|
+
});
|
|
3337
|
+
} catch (thrown) {
|
|
3338
|
+
return throwOgygieError(
|
|
3339
|
+
{
|
|
3340
|
+
code: "MODEL_UNAVAILABLE",
|
|
3341
|
+
message: "The model API could not be reached.",
|
|
3342
|
+
file: null,
|
|
3343
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3344
|
+
suggestedFix: "Retry once the network is back; nothing was written."
|
|
3345
|
+
},
|
|
3346
|
+
502
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
if (!response.ok) {
|
|
3350
|
+
const body = await response.text();
|
|
3351
|
+
const budget = response.status === 400 && /credit|billing|spend|budget/i.test(body);
|
|
3352
|
+
return throwOgygieError(
|
|
3353
|
+
{
|
|
3354
|
+
code: budget ? "MODEL_BUDGET_EXCEEDED" : "MODEL_UNAVAILABLE",
|
|
3355
|
+
message: budget ? "The model workspace refused the call: its spending limit is reached." : `The model API answered ${response.status}.`,
|
|
3356
|
+
file: null,
|
|
3357
|
+
cause: `status=${response.status} ${body.slice(0, 300)}`,
|
|
3358
|
+
suggestedFix: budget ? "The monthly budget of this workspace is spent; it resets with the month, or the operator raises it." : `Check ${credential} and retry; nothing was written.`
|
|
3359
|
+
},
|
|
3360
|
+
budget ? 429 : 502
|
|
3361
|
+
);
|
|
3362
|
+
}
|
|
3363
|
+
const answer = await response.json();
|
|
3364
|
+
return {
|
|
3365
|
+
text: answer.content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text ?? "").join(""),
|
|
3366
|
+
model: answer.model,
|
|
3367
|
+
stopReason: answer.stop_reason,
|
|
3368
|
+
usage: { inputTokens: answer.usage.input_tokens, outputTokens: answer.usage.output_tokens }
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// src/server/hosted/model.ts
|
|
3375
|
+
function createAnthropicSpecWriter(deps) {
|
|
3376
|
+
const client = createMessagesClient({
|
|
3377
|
+
apiKey: deps.apiKey,
|
|
3378
|
+
fetchImpl: deps.fetchImpl,
|
|
3379
|
+
...deps.apiBase === void 0 ? {} : { apiBase: deps.apiBase },
|
|
3380
|
+
credentialHint: "OGYGIE_MODEL_API_KEY"
|
|
3381
|
+
});
|
|
3382
|
+
return {
|
|
3383
|
+
async write(input) {
|
|
3384
|
+
const messages = [{ role: "user", content: input.user }];
|
|
3385
|
+
if (input.previous !== void 0) {
|
|
3386
|
+
messages.push({ role: "assistant", content: input.previous.draft });
|
|
3387
|
+
messages.push({
|
|
3388
|
+
role: "user",
|
|
3389
|
+
content: `Ce brouillon est refus\xE9 par les gates du repo pour les raisons suivantes :
|
|
3390
|
+
- ${input.previous.problems.join("\n- ")}
|
|
3391
|
+
|
|
3392
|
+
R\xE9\xE9cris la spec COMPL\xC8TE, corrig\xE9e, sans autre commentaire.`
|
|
3393
|
+
});
|
|
3394
|
+
}
|
|
3395
|
+
const answer = await client.complete({ model: deps.model, system: input.system, messages, maxTokens: deps.maxTokens ?? 16e3, effort: "high" });
|
|
3396
|
+
if (answer.stopReason === "refusal") {
|
|
3397
|
+
return throwOgygieError(
|
|
3398
|
+
{
|
|
3399
|
+
code: "MODEL_REFUSED",
|
|
3400
|
+
message: "The model declined to write this brief.",
|
|
3401
|
+
file: null,
|
|
3402
|
+
cause: "stop_reason=refusal",
|
|
3403
|
+
suggestedFix: "Rephrase the brief as a product need; the studio writes specs, nothing else."
|
|
3404
|
+
},
|
|
3405
|
+
422
|
|
3406
|
+
);
|
|
3407
|
+
}
|
|
3408
|
+
if (answer.stopReason === "max_tokens") {
|
|
3409
|
+
return throwOgygieError(
|
|
3410
|
+
{
|
|
3411
|
+
code: "MODEL_OUTPUT_TRUNCATED",
|
|
3412
|
+
message: "The draft was cut before its end.",
|
|
3413
|
+
file: null,
|
|
3414
|
+
cause: `stop_reason=max_tokens at ${deps.maxTokens ?? 16e3} tokens`,
|
|
3415
|
+
suggestedFix: "Split the brief: one rubric per brief, one spec per rubric."
|
|
3416
|
+
},
|
|
3417
|
+
422
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
return { text: answer.text, model: answer.model, usage: answer.usage };
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// src/server/hosted/github-pulls.ts
|
|
3426
|
+
var RED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "action_required", "startup_failure"]);
|
|
3427
|
+
function checksStateOf(runs, statuses) {
|
|
3428
|
+
if (runs.length === 0 && statuses.length === 0) return "none";
|
|
3429
|
+
const red = runs.some((run) => run.conclusion !== null && RED_CONCLUSIONS.has(run.conclusion)) || statuses.some((status) => status.state === "failure" || status.state === "error");
|
|
3430
|
+
if (red) return "red";
|
|
3431
|
+
const pending = runs.some((run) => run.status !== "completed") || statuses.some((status) => status.state === "pending");
|
|
3432
|
+
return pending ? "pending" : "green";
|
|
3433
|
+
}
|
|
3434
|
+
function createGithubPullsClient(deps) {
|
|
3435
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
3436
|
+
const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
|
|
3437
|
+
async function request(method, url, body, allow = []) {
|
|
3438
|
+
let response;
|
|
3439
|
+
try {
|
|
3440
|
+
response = await deps.fetchImpl(url, {
|
|
3441
|
+
method,
|
|
3442
|
+
headers: {
|
|
3443
|
+
accept: "application/vnd.github+json",
|
|
3444
|
+
"x-github-api-version": "2022-11-28",
|
|
3445
|
+
"user-agent": "ogygie-studio",
|
|
3446
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
3447
|
+
},
|
|
3448
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
3449
|
+
});
|
|
3450
|
+
} catch (thrown) {
|
|
3451
|
+
return throwOgygieError({
|
|
3452
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3453
|
+
message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
|
|
3454
|
+
file: null,
|
|
3455
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3456
|
+
suggestedFix: "Retry once the network is back; nothing was merged."
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
if (allow.includes(response.status)) return { status: response.status, json: null };
|
|
3460
|
+
if (!response.ok) {
|
|
3461
|
+
return throwOgygieError({
|
|
3462
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3463
|
+
message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
|
|
3464
|
+
file: null,
|
|
3465
|
+
cause: `status=${response.status}`,
|
|
3466
|
+
suggestedFix: "Retry later; check the app installation grants contents and pull requests on this repo."
|
|
3467
|
+
});
|
|
3468
|
+
}
|
|
3469
|
+
const text = await response.text();
|
|
3470
|
+
return { status: response.status, json: text === "" ? null : JSON.parse(text) };
|
|
3471
|
+
}
|
|
3472
|
+
return {
|
|
3473
|
+
async findOpenPullRequest(branch) {
|
|
3474
|
+
const { json } = await request(
|
|
3475
|
+
"GET",
|
|
3476
|
+
`${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${deps.github.owner}:${branch}`)}`,
|
|
3477
|
+
void 0
|
|
3478
|
+
);
|
|
3479
|
+
const first = json?.[0];
|
|
3480
|
+
return first === void 0 ? null : { number: first.number, url: first.html_url, headSha: first.head.sha, title: first.title, labels: (first.labels ?? []).map((label) => label.name) };
|
|
3481
|
+
},
|
|
3482
|
+
async listOpenSpecPullRequests() {
|
|
3483
|
+
const { json } = await request(
|
|
3484
|
+
"GET",
|
|
3485
|
+
`${repoUrl}/pulls?state=open&sort=updated&direction=desc&per_page=100`,
|
|
3486
|
+
void 0
|
|
3487
|
+
);
|
|
3488
|
+
return (json ?? []).filter((pull) => pull.head.ref.startsWith("spec/")).map((pull) => ({ number: pull.number, url: pull.html_url, headSha: pull.head.sha, title: pull.title, branch: pull.head.ref, labels: (pull.labels ?? []).map((label) => label.name) }));
|
|
3489
|
+
},
|
|
3490
|
+
async listFileCommits(branch, path) {
|
|
3491
|
+
const { json } = await request(
|
|
3492
|
+
"GET",
|
|
3493
|
+
`${repoUrl}/commits?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=50`,
|
|
3494
|
+
void 0
|
|
3495
|
+
);
|
|
3496
|
+
return (json ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message }));
|
|
3497
|
+
},
|
|
3498
|
+
async getPullRequest(number) {
|
|
3499
|
+
const { json } = await request(
|
|
3500
|
+
"GET",
|
|
3501
|
+
`${repoUrl}/pulls/${number}`,
|
|
3502
|
+
void 0
|
|
3503
|
+
);
|
|
3504
|
+
if (json === null) throw new Error("empty pull request answer");
|
|
3505
|
+
return { number: json.number, url: json.html_url, headSha: json.head.sha, mergeable: json.mergeable, merged: json.merged };
|
|
3506
|
+
},
|
|
3507
|
+
async getChecks(sha) {
|
|
3508
|
+
const [runsAnswer, statusAnswer] = await Promise.all([
|
|
3509
|
+
request("GET", `${repoUrl}/commits/${sha}/check-runs?per_page=100`, void 0),
|
|
3510
|
+
request("GET", `${repoUrl}/commits/${sha}/status`, void 0)
|
|
3511
|
+
]);
|
|
3512
|
+
const runs = runsAnswer.json?.check_runs ?? [];
|
|
3513
|
+
const statuses = statusAnswer.json?.statuses ?? [];
|
|
3514
|
+
return {
|
|
3515
|
+
state: checksStateOf(runs, statuses),
|
|
3516
|
+
runs: [
|
|
3517
|
+
...runs.map((run) => ({ name: run.name, conclusion: run.status === "completed" ? run.conclusion : "pending" })),
|
|
3518
|
+
...statuses.map((status) => ({ name: status.context, conclusion: status.state }))
|
|
3519
|
+
]
|
|
3520
|
+
};
|
|
3521
|
+
},
|
|
3522
|
+
async mergePullRequest(number, input) {
|
|
3523
|
+
const { status, json } = await request(
|
|
3524
|
+
"PUT",
|
|
3525
|
+
`${repoUrl}/pulls/${number}/merge`,
|
|
3526
|
+
{ merge_method: "squash", commit_title: input.title, commit_message: input.message },
|
|
3527
|
+
[405, 409]
|
|
3528
|
+
);
|
|
3529
|
+
if (status === 405 || status === 409) {
|
|
3530
|
+
return throwOgygieError(
|
|
3531
|
+
{
|
|
3532
|
+
code: "PULL_REQUEST_CONFLICTING",
|
|
3533
|
+
message: `GitHub refused to merge pull request #${number} (${status}).`,
|
|
3534
|
+
file: null,
|
|
3535
|
+
cause: status === 409 ? "the head moved since it was read" : "not mergeable: conflict, or a required check missing",
|
|
3536
|
+
suggestedFix: "Regenerate the branch on the current default branch (operator), then merge again."
|
|
3537
|
+
},
|
|
3538
|
+
409
|
|
3539
|
+
);
|
|
3540
|
+
}
|
|
3541
|
+
return { sha: json?.sha ?? "" };
|
|
3542
|
+
},
|
|
3543
|
+
async deleteBranch(branch) {
|
|
3544
|
+
await request("DELETE", `${repoUrl}/git/refs/heads/${encodeURIComponent(branch)}`, void 0, [404, 422]);
|
|
3545
|
+
}
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
// src/server/hosted/github-deploy.ts
|
|
3550
|
+
function createGithubDeployClient(deps) {
|
|
3551
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
3552
|
+
const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
|
|
3553
|
+
async function request(method, url, body, allowNotFound = false) {
|
|
3554
|
+
let response;
|
|
3555
|
+
try {
|
|
3556
|
+
response = await deps.fetchImpl(url, {
|
|
3557
|
+
method,
|
|
3558
|
+
headers: {
|
|
3559
|
+
accept: "application/vnd.github+json",
|
|
3560
|
+
"x-github-api-version": "2022-11-28",
|
|
3561
|
+
"user-agent": "ogygie-studio",
|
|
3562
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
3563
|
+
},
|
|
3564
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
3565
|
+
});
|
|
3566
|
+
} catch (thrown) {
|
|
3567
|
+
return throwOgygieError({
|
|
3568
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3569
|
+
message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
|
|
3570
|
+
file: null,
|
|
3571
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3572
|
+
suggestedFix: "Retry once the network is back; no ref was moved."
|
|
3573
|
+
});
|
|
3574
|
+
}
|
|
3575
|
+
if (response.status === 404 && allowNotFound) return null;
|
|
3576
|
+
if (!response.ok) {
|
|
3577
|
+
return throwOgygieError({
|
|
3578
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3579
|
+
message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
|
|
3580
|
+
file: null,
|
|
3581
|
+
cause: `status=${response.status}`,
|
|
3582
|
+
suggestedFix: response.status === 422 ? "Check the ref and the sha: a protected ref only moves by the installation, in fast-forward unless forced." : "Retry later; check the app installation grants contents and deployments on this repo."
|
|
3583
|
+
});
|
|
3584
|
+
}
|
|
3585
|
+
const text = await response.text();
|
|
3586
|
+
return text === "" ? null : JSON.parse(text);
|
|
3587
|
+
}
|
|
3588
|
+
return {
|
|
3589
|
+
async getRefSha(ref) {
|
|
3590
|
+
const answer = await request("GET", `${repoUrl}/git/ref/heads/${encodeURIComponent(ref)}`, void 0, true);
|
|
3591
|
+
return answer?.object.sha ?? null;
|
|
3592
|
+
},
|
|
3593
|
+
async getCommitSha(shaOrRef) {
|
|
3594
|
+
const answer = await request("GET", `${repoUrl}/commits/${encodeURIComponent(shaOrRef)}`, void 0, true);
|
|
3595
|
+
return answer?.sha ?? null;
|
|
3596
|
+
},
|
|
3597
|
+
async compare(base, head) {
|
|
3598
|
+
const answer = await request(
|
|
3599
|
+
"GET",
|
|
3600
|
+
`${repoUrl}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}?per_page=250`,
|
|
3601
|
+
void 0
|
|
3602
|
+
);
|
|
3603
|
+
return {
|
|
3604
|
+
status: answer?.status ?? "diverged",
|
|
3605
|
+
commits: (answer?.commits ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message, authoredAt: entry.commit.author.date }))
|
|
3606
|
+
};
|
|
3607
|
+
},
|
|
3608
|
+
async updateRef(ref, sha, force) {
|
|
3609
|
+
await request("PATCH", `${repoUrl}/git/refs/heads/${encodeURIComponent(ref)}`, { sha, force });
|
|
3610
|
+
},
|
|
3611
|
+
async createDeployment(input) {
|
|
3612
|
+
const answer = await request("POST", `${repoUrl}/deployments`, {
|
|
3613
|
+
ref: input.sha,
|
|
3614
|
+
environment: input.environment,
|
|
3615
|
+
description: input.description,
|
|
3616
|
+
payload: input.payload,
|
|
3617
|
+
auto_merge: false,
|
|
3618
|
+
required_contexts: [],
|
|
3619
|
+
production_environment: input.environment === "production"
|
|
3620
|
+
});
|
|
3621
|
+
return { id: answer?.id ?? 0 };
|
|
3622
|
+
},
|
|
3623
|
+
async latestDeployment(environment) {
|
|
3624
|
+
const deployments = await request(
|
|
3625
|
+
"GET",
|
|
3626
|
+
`${repoUrl}/deployments?environment=${encodeURIComponent(environment)}&per_page=1`,
|
|
3627
|
+
void 0
|
|
3628
|
+
);
|
|
3629
|
+
const latest = deployments?.[0];
|
|
3630
|
+
if (latest === void 0) return null;
|
|
3631
|
+
const statuses = await request("GET", `${repoUrl}/deployments/${latest.id}/statuses?per_page=1`, void 0);
|
|
3632
|
+
return { id: latest.id, sha: latest.sha, createdAt: latest.created_at, state: statuses?.[0]?.state ?? null };
|
|
3633
|
+
},
|
|
3634
|
+
async listTags(prefix) {
|
|
3635
|
+
const refs = await request("GET", `${repoUrl}/git/matching-refs/tags/${prefix}`, void 0);
|
|
3636
|
+
return (refs ?? []).map((entry) => entry.ref.replace(/^refs\/tags\//, ""));
|
|
3637
|
+
},
|
|
3638
|
+
async createTag(input) {
|
|
3639
|
+
const tag = await request("POST", `${repoUrl}/git/tags`, {
|
|
3640
|
+
tag: input.name,
|
|
3641
|
+
message: input.message,
|
|
3642
|
+
object: input.sha,
|
|
3643
|
+
type: "commit",
|
|
3644
|
+
tagger: { ...input.tagger, date: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3645
|
+
});
|
|
3646
|
+
await request("POST", `${repoUrl}/git/refs`, { ref: `refs/tags/${input.name}`, sha: tag?.sha ?? input.sha });
|
|
3647
|
+
},
|
|
3648
|
+
async getPullRequestBody(number) {
|
|
3649
|
+
const pull = await request("GET", `${repoUrl}/pulls/${number}`, void 0, true);
|
|
3650
|
+
return pull?.body ?? "";
|
|
3651
|
+
}
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
// src/server/hosted/github-usage.ts
|
|
3656
|
+
function createGithubUsageClient(deps) {
|
|
3657
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
3658
|
+
const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
|
|
3659
|
+
async function request(method, url, body) {
|
|
3660
|
+
let response;
|
|
3661
|
+
try {
|
|
3662
|
+
response = await deps.fetchImpl(url, {
|
|
3663
|
+
method,
|
|
3664
|
+
headers: {
|
|
3665
|
+
accept: "application/vnd.github+json",
|
|
3666
|
+
"x-github-api-version": "2022-11-28",
|
|
3667
|
+
"user-agent": "ogygie-studio",
|
|
3668
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
3669
|
+
},
|
|
3670
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
3671
|
+
});
|
|
3672
|
+
} catch (thrown) {
|
|
3673
|
+
return throwOgygieError({
|
|
3674
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3675
|
+
message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
|
|
3676
|
+
file: null,
|
|
3677
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3678
|
+
suggestedFix: "Retry once the network is back."
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
if (!response.ok) {
|
|
3682
|
+
return throwOgygieError({
|
|
3683
|
+
code: "GITHUB_UNAVAILABLE",
|
|
3684
|
+
message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
|
|
3685
|
+
file: null,
|
|
3686
|
+
cause: `status=${response.status}`,
|
|
3687
|
+
suggestedFix: "Retry later; check the app installation grants pull requests (write) on this repo."
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
const text = await response.text();
|
|
3691
|
+
return text === "" ? null : JSON.parse(text);
|
|
3692
|
+
}
|
|
3693
|
+
async function journalComment(pullRequestNumber) {
|
|
3694
|
+
const comments = await request("GET", `${repoUrl}/issues/${pullRequestNumber}/comments?per_page=100`) ?? [];
|
|
3695
|
+
const found = comments.find((comment) => comment.body.includes(USAGE_MARKER));
|
|
3696
|
+
return found === void 0 ? null : { id: found.id, entries: parseUsageComment(found.body) };
|
|
3697
|
+
}
|
|
3698
|
+
return {
|
|
3699
|
+
async appendUsage(pullRequestNumber, entries) {
|
|
3700
|
+
const existing = await journalComment(pullRequestNumber);
|
|
3701
|
+
const body = formatUsageComment([...existing?.entries ?? [], ...entries]);
|
|
3702
|
+
if (existing === null) {
|
|
3703
|
+
await request("POST", `${repoUrl}/issues/${pullRequestNumber}/comments`, { body });
|
|
3704
|
+
} else {
|
|
3705
|
+
await request("PATCH", `${repoUrl}/issues/comments/${existing.id}`, { body });
|
|
3706
|
+
}
|
|
3707
|
+
},
|
|
3708
|
+
async journalsSince(sinceIso) {
|
|
3709
|
+
const pulls = await request(
|
|
3710
|
+
"GET",
|
|
3711
|
+
`${repoUrl}/pulls?state=all&sort=updated&direction=desc&per_page=100`
|
|
3712
|
+
) ?? [];
|
|
3713
|
+
const journals = [];
|
|
3714
|
+
for (const pull of pulls) {
|
|
3715
|
+
if (pull.updated_at < sinceIso) break;
|
|
3716
|
+
if (!pull.head.ref.startsWith("spec/")) continue;
|
|
3717
|
+
const comment = await journalComment(pull.number);
|
|
3718
|
+
if (comment === null || comment.entries.length === 0) continue;
|
|
3719
|
+
journals.push({ specId: pull.head.ref.slice("spec/".length).replace(/-[0-9a-f]{7}$/, ""), pullRequestNumber: pull.number, entries: comment.entries });
|
|
3720
|
+
}
|
|
3721
|
+
return journals;
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
}
|
|
3725
|
+
|
|
1107
3726
|
// src/server/start.ts
|
|
1108
3727
|
function studioPackageRoot() {
|
|
1109
3728
|
let directory = dirname2(fileURLToPath(import.meta.url));
|
|
@@ -1159,7 +3778,142 @@ async function startStudio(options) {
|
|
|
1159
3778
|
if (options.openBrowser !== false) openInBrowser(url);
|
|
1160
3779
|
return app;
|
|
1161
3780
|
}
|
|
3781
|
+
var MIN_SESSION_SECRET_LENGTH = 32;
|
|
3782
|
+
function readSecret(path, what, minLength = 1) {
|
|
3783
|
+
let value = "";
|
|
3784
|
+
try {
|
|
3785
|
+
value = readFileSync(resolvePath(path), "utf8").trim();
|
|
3786
|
+
} catch {
|
|
3787
|
+
value = "";
|
|
3788
|
+
}
|
|
3789
|
+
if (value.length < minLength) {
|
|
3790
|
+
throwOgygieError({
|
|
3791
|
+
code: "HOST_SECRET_MISSING",
|
|
3792
|
+
message: `The hosted studio cannot start: ${what} is missing or too short.`,
|
|
3793
|
+
file: path,
|
|
3794
|
+
cause: value.length === 0 ? "file absent or empty" : `${value.length} characters, minimum ${minLength}`,
|
|
3795
|
+
suggestedFix: `Write ${what} to that path (chmod 600) \u2014 a half-configured hosted mode is an error, not a degraded mode.`
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
return value;
|
|
3799
|
+
}
|
|
3800
|
+
function resolvePath(path) {
|
|
3801
|
+
return path.startsWith("~/") ? join2(process.env["HOME"] ?? "", path.slice(2)) : path;
|
|
3802
|
+
}
|
|
3803
|
+
function loadHostConfig(configPath) {
|
|
3804
|
+
let raw;
|
|
3805
|
+
try {
|
|
3806
|
+
raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
3807
|
+
} catch (thrown) {
|
|
3808
|
+
return throwOgygieError({
|
|
3809
|
+
code: "HOST_CONFIG_INVALID",
|
|
3810
|
+
message: "The hosted studio configuration cannot be read as JSON.",
|
|
3811
|
+
file: configPath,
|
|
3812
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
3813
|
+
suggestedFix: "Point --config at a readable ogygie-host.config.json."
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
return parseHostConfig(raw, configPath);
|
|
3817
|
+
}
|
|
3818
|
+
async function startHostedStudio(options) {
|
|
3819
|
+
const config = loadHostConfig(options.configPath);
|
|
3820
|
+
const privateKeyPem = readSecret(config.githubApp.privateKeyPath, "the GitHub App private key");
|
|
3821
|
+
const sessionSecret = readSecret(config.session.secretPath, "the session secret", MIN_SESSION_SECRET_LENGTH);
|
|
3822
|
+
const mailApiKey = readSecret(config.mail.apiKeyPath, "the mail provider key");
|
|
3823
|
+
const modelApiKey = config.model === void 0 ? void 0 : process.env["OGYGIE_MODEL_API_KEY"] ?? "";
|
|
3824
|
+
if (modelApiKey !== void 0 && modelApiKey.length === 0) {
|
|
3825
|
+
throwOgygieError({
|
|
3826
|
+
code: "HOST_SECRET_MISSING",
|
|
3827
|
+
message: "The hosted studio cannot start: the configuration declares a model block but OGYGIE_MODEL_API_KEY is absent.",
|
|
3828
|
+
file: options.configPath,
|
|
3829
|
+
cause: "model block present, environment variable empty",
|
|
3830
|
+
suggestedFix: "Export OGYGIE_MODEL_API_KEY (the client's model workspace key, D50) in the instance environment, or remove the model block to run without briefs."
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3833
|
+
const uiDistDir = studioUiDir();
|
|
3834
|
+
await buildUiIfMissing(uiDistDir);
|
|
3835
|
+
const now = () => Date.now();
|
|
3836
|
+
const log = (line) => {
|
|
3837
|
+
process.stderr.write(`${line}
|
|
3838
|
+
`);
|
|
3839
|
+
};
|
|
3840
|
+
const githubApp = createGithubAppAuth({
|
|
3841
|
+
appId: config.githubApp.appId,
|
|
3842
|
+
installationId: config.githubApp.installationId,
|
|
3843
|
+
privateKeyPem,
|
|
3844
|
+
fetchImpl: fetch,
|
|
3845
|
+
now
|
|
3846
|
+
});
|
|
3847
|
+
const repoId = config.repo.repo;
|
|
3848
|
+
const recentProposals = /* @__PURE__ */ new Map();
|
|
3849
|
+
const listSpecBranches = createGithubSpecBranchLister({ github: config.repo, fetchImpl: githubApp.installationFetch });
|
|
3850
|
+
const readerFor = (ref) => createGithubApiRepoAdapter({
|
|
3851
|
+
owner: config.repo.owner,
|
|
3852
|
+
repo: config.repo.repo,
|
|
3853
|
+
ref,
|
|
3854
|
+
fetchImpl: githubApp.installationFetch,
|
|
3855
|
+
userAgent: "ogygie-studio"
|
|
3856
|
+
});
|
|
3857
|
+
const client = createGithubWriteClient(
|
|
3858
|
+
{ token: "installation", fetchImpl: githubApp.installationFetch, userAgent: "ogygie-studio", credentialHint: "the GitHub App installation" },
|
|
3859
|
+
config.repo
|
|
3860
|
+
);
|
|
3861
|
+
const deploy = config.environments === void 0 ? void 0 : createGithubDeployClient({ github: config.repo, fetchImpl: githubApp.installationFetch });
|
|
3862
|
+
if (deploy !== void 0) {
|
|
3863
|
+
for (const ref of Object.values(ENVIRONMENT_REFS)) {
|
|
3864
|
+
if (await deploy.getRefSha(ref) === null) {
|
|
3865
|
+
throwOgygieError({
|
|
3866
|
+
code: "HOST_ENVIRONMENT_REF_MISSING",
|
|
3867
|
+
message: `The hosted studio cannot start: the environment ref ${ref} does not exist on ${config.repo.owner}/${config.repo.repo}.`,
|
|
3868
|
+
file: options.configPath,
|
|
3869
|
+
cause: `environments declared, ${ref} absent`,
|
|
3870
|
+
suggestedFix: "Create env/staging and env/production from the default branch and protect them (docs/HOSTED-STUDIO.md \xA77), or remove the environments block."
|
|
3871
|
+
});
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
const app = await createStudioApp({
|
|
3876
|
+
uiDistDir,
|
|
3877
|
+
hosted: {
|
|
3878
|
+
config,
|
|
3879
|
+
sessionSecret,
|
|
3880
|
+
mail: createHttpMailSender({
|
|
3881
|
+
providerUrl: config.mail.providerUrl,
|
|
3882
|
+
apiKey: mailApiKey,
|
|
3883
|
+
fromAddress: config.mail.fromAddress,
|
|
3884
|
+
fetchImpl: fetch
|
|
3885
|
+
}),
|
|
3886
|
+
now,
|
|
3887
|
+
log,
|
|
3888
|
+
consumedLinks: /* @__PURE__ */ new Map(),
|
|
3889
|
+
pulls: createGithubPullsClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
|
|
3890
|
+
readerFor,
|
|
3891
|
+
usage: createGithubUsageClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
|
|
3892
|
+
...deploy === void 0 ? {} : { deploy },
|
|
3893
|
+
...modelApiKey === void 0 || config.model === void 0 ? {} : { writer: createAnthropicSpecWriter({ apiKey: modelApiKey, model: config.model.specModel, fetchImpl: fetch }) },
|
|
3894
|
+
storageFor: (user) => createHostedSpecStorage({
|
|
3895
|
+
repoId,
|
|
3896
|
+
github: config.repo,
|
|
3897
|
+
readerFor,
|
|
3898
|
+
listSpecBranches,
|
|
3899
|
+
client,
|
|
3900
|
+
user,
|
|
3901
|
+
now,
|
|
3902
|
+
rateLimitPerHour: config.writeRateLimit,
|
|
3903
|
+
recentProposals,
|
|
3904
|
+
log
|
|
3905
|
+
})
|
|
3906
|
+
}
|
|
3907
|
+
});
|
|
3908
|
+
const host = options.host ?? "127.0.0.1";
|
|
3909
|
+
const port = options.port ?? 4001;
|
|
3910
|
+
await app.listen({ port, host });
|
|
3911
|
+
console.log(`ogygie studio \u2014 mode h\xE9berg\xE9 sur ${host}:${port}, repo ${config.repo.owner}/${config.repo.repo}, ${config.users.length} utilisateur(s) d\xE9clar\xE9(s), briefs ${modelApiKey === void 0 ? "d\xE9sactiv\xE9s (pas de bloc model)" : `par ${config.model?.specModel ?? ""}`}`);
|
|
3912
|
+
return app;
|
|
3913
|
+
}
|
|
1162
3914
|
export {
|
|
3915
|
+
loadHostConfig,
|
|
3916
|
+
startHostedStudio,
|
|
1163
3917
|
startStudio,
|
|
1164
3918
|
studioUiDir
|
|
1165
3919
|
};
|