@joystyk/content 0.0.0-stage → 0.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +113 -2
  5. package/dist/banks.d.ts +14 -0
  6. package/dist/banks.js +55 -0
  7. package/dist/banks.js.map +1 -0
  8. package/dist/extract-json.d.ts +12 -0
  9. package/dist/extract-json.js +59 -0
  10. package/dist/extract-json.js.map +1 -0
  11. package/dist/filter.d.ts +56 -0
  12. package/dist/filter.js +127 -0
  13. package/dist/filter.js.map +1 -0
  14. package/dist/games.d.ts +9 -0
  15. package/dist/games.js +40 -0
  16. package/dist/games.js.map +1 -0
  17. package/dist/hash.d.ts +4 -0
  18. package/dist/hash.js +17 -0
  19. package/dist/hash.js.map +1 -0
  20. package/dist/index.d.ts +17 -0
  21. package/dist/index.js +16 -0
  22. package/dist/log.d.ts +26 -0
  23. package/dist/log.js +58 -0
  24. package/dist/log.js.map +1 -0
  25. package/dist/model.d.ts +77 -0
  26. package/dist/model.js +268 -0
  27. package/dist/model.js.map +1 -0
  28. package/dist/packs.d.ts +144 -0
  29. package/dist/packs.js +466 -0
  30. package/dist/packs.js.map +1 -0
  31. package/dist/pipeline.d.ts +252 -0
  32. package/dist/pipeline.js +558 -0
  33. package/dist/pipeline.js.map +1 -0
  34. package/dist/publish.d.ts +21 -0
  35. package/dist/publish.js +56 -0
  36. package/dist/publish.js.map +1 -0
  37. package/dist/ranked.d.ts +12 -0
  38. package/dist/ranked.js +40 -0
  39. package/dist/ranked.js.map +1 -0
  40. package/dist/screen.d.ts +25 -0
  41. package/dist/screen.js +46 -0
  42. package/dist/screen.js.map +1 -0
  43. package/dist/store.d.ts +136 -0
  44. package/dist/store.js +300 -0
  45. package/dist/store.js.map +1 -0
  46. package/dist/text.d.ts +31 -0
  47. package/dist/text.js +111 -0
  48. package/dist/text.js.map +1 -0
  49. package/dist/vocabulary.d.ts +16 -0
  50. package/dist/vocabulary.js +44 -0
  51. package/dist/vocabulary.js.map +1 -0
  52. package/lists/SCOWL-COPYRIGHT +252 -0
  53. package/lists/blocklist.txt +214 -0
  54. package/lists/common-extra.txt +13 -0
  55. package/lists/scowl-common.txt +70763 -0
  56. package/lists/scowl-ranked.txt +72076 -0
  57. package/lists/scowl-vocabulary.txt +55636 -0
  58. package/lists/sensitive.txt +280 -0
  59. package/package.json +53 -3
@@ -0,0 +1,144 @@
1
+ import type { Json, Result, StandardSchemaV1 } from '@joystyk/contracts';
2
+ import type { FieldText, WordFilter } from './filter.js';
3
+ import { type Logger } from './log.js';
4
+ import type { ModelClient } from './model.js';
5
+ import { type AttemptReport, type GenerateFailure } from './pipeline.js';
6
+ import type { PublishedFile } from './publish.js';
7
+ import type { ContentStore, ItemRow, WithdrawReason } from './store.js';
8
+ export declare const PACK_SCHEMA_VERSION = 1;
9
+ /** One slot a game keeps filled, such as a theme. Slugs are stable: they name files and items. */
10
+ export interface PackSlot<Inputs extends Json = Json> {
11
+ readonly slug: string;
12
+ readonly inputs: Inputs;
13
+ }
14
+ export interface PackGenerateContext<Inputs extends Json> {
15
+ readonly itemId: string;
16
+ readonly slug: string;
17
+ readonly inputs: Inputs;
18
+ /** Deterministic per item: the generator draws any randomness (gap positions) from it. */
19
+ readonly seed: string;
20
+ /** 1-based, within this run. */
21
+ readonly attempt: number;
22
+ /** What was wrong with the previous attempt, in words the prompt can repeat. Empty on the first. */
23
+ readonly feedback: readonly string[];
24
+ readonly model: ModelClient;
25
+ readonly signal?: AbortSignal;
26
+ }
27
+ export interface PackGenerator<Config extends Json = Json, Inputs extends Json = Json> {
28
+ readonly gameId: string;
29
+ /** Recorded with every item, e.g. "letter-drop-v1". */
30
+ readonly promptVersion: string;
31
+ /** The game's pack schema: every pack is decoded through it before anything else. */
32
+ readonly configSchema: StandardSchemaV1<unknown, Config>;
33
+ /** The curated slots to keep filled. Deterministic: no player text ever reaches a prompt. */
34
+ slots(): readonly PackSlot<Inputs>[];
35
+ generate(context: PackGenerateContext<Inputs>): Promise<Result<Config, GenerateFailure>>;
36
+ /** The game validators: pure; an empty list means the pack passes. */
37
+ validate(config: Config, context: {
38
+ readonly itemId: string;
39
+ readonly slug: string;
40
+ readonly inputs: Inputs | null;
41
+ }): readonly string[];
42
+ /** Every player-visible text, for the all-ages filter and the model screen. */
43
+ texts(config: Config): readonly FieldText[];
44
+ /** The reviewed fallback pack for a slot, or null when the bank has none. */
45
+ bank(slot: PackSlot<Inputs>, itemId: string): Config | null;
46
+ }
47
+ export declare const packItemId: (gameId: string, slug: string) => string;
48
+ /** The slug of a pack item id, or null when the id is not a pack's. */
49
+ export declare const packSlugOf: (gameId: string, itemId: string) => string | null;
50
+ export interface PackIndexEntry {
51
+ readonly slug: string;
52
+ readonly hash: string;
53
+ readonly size: number;
54
+ }
55
+ export interface PackIndex {
56
+ readonly schemaVersion: typeof PACK_SCHEMA_VERSION;
57
+ readonly gameId: string;
58
+ readonly packs: readonly PackIndexEntry[];
59
+ }
60
+ export interface PackFile {
61
+ readonly schemaVersion: typeof PACK_SCHEMA_VERSION;
62
+ readonly itemId: string;
63
+ readonly slug: string;
64
+ readonly contentHash: string;
65
+ readonly config: Json;
66
+ }
67
+ export interface PackPublisher {
68
+ readonly dir: string;
69
+ /** Writes one pack file (if it isn't there already) and returns its hash and size. */
70
+ writePack(gameId: string, slug: string, config: Json): PublishedFile;
71
+ /** Replaces a game's pack index. */
72
+ writeIndex(gameId: string, packs: readonly PackIndexEntry[]): void;
73
+ }
74
+ /** The pack file body for a config: the envelope, as compact JSON. */
75
+ export declare const packFileBody: (gameId: string, slug: string, config: Json) => {
76
+ body: string;
77
+ hash: string;
78
+ };
79
+ /** Pack files under `<dir>/<game>/packs/`, written atomically; a file named by its hash is never rewritten. */
80
+ export declare const createPackPublisher: (dir: string) => PackPublisher;
81
+ /** Rewrites a game's pack index from the store: every current published pack. */
82
+ export declare const rebuildPackIndex: (store: ContentStore, publisher: PackPublisher, gameId: string) => void;
83
+ export type PackAction = 'existing' | 'generated' | 'replaced-bank' | 'bank' | 'failed' | 'aborted';
84
+ export interface PackSlotReport {
85
+ readonly gameId: string;
86
+ readonly slug: string;
87
+ readonly itemId: string;
88
+ readonly action: PackAction;
89
+ readonly status: string;
90
+ readonly source: string | null;
91
+ readonly attempts: readonly AttemptReport[];
92
+ readonly contentHash: string | null;
93
+ readonly config: Json | null;
94
+ }
95
+ export interface PackRunReport {
96
+ readonly runId: number;
97
+ readonly status: 'ok' | 'partial' | 'failed' | 'aborted';
98
+ readonly slots: readonly PackSlotReport[];
99
+ readonly published: readonly (PublishedFile & {
100
+ readonly gameId: string;
101
+ readonly slug: string;
102
+ })[];
103
+ readonly breakerOpen: boolean;
104
+ }
105
+ export interface PackPipelineOptions {
106
+ readonly store: ContentStore;
107
+ readonly generators: readonly PackGenerator[];
108
+ /** Null runs without a model: validated packs publish and empty slots take bank packs. */
109
+ readonly model: ModelClient | null;
110
+ readonly filter: WordFilter;
111
+ /** Null for a dry run: nothing is written. */
112
+ readonly publisher: PackPublisher | null;
113
+ readonly now?: () => number;
114
+ /** Only these games, and only these slugs. */
115
+ readonly games?: readonly string[];
116
+ readonly slugs?: readonly string[];
117
+ /** Publish a failed slot's bank pack (default true). `content bank`, which grows the banks, turns it off. */
118
+ readonly bankFallback?: boolean;
119
+ readonly maxAttempts?: number;
120
+ readonly breakerThreshold?: number;
121
+ /** Run the model screen (layer 3). Default true. */
122
+ readonly screen?: boolean;
123
+ readonly backoffMs?: number;
124
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
125
+ readonly signal?: AbortSignal;
126
+ readonly log?: Logger;
127
+ readonly trigger?: string;
128
+ }
129
+ export declare const runPackPipeline: (options: PackPipelineOptions) => Promise<PackRunReport>;
130
+ /**
131
+ * Takes a pack down (content-pipeline.md, "Corrections"): withdrawn with a reason code and
132
+ * actor, dropped from the index at once; the next run fills the slot again. Games already
133
+ * started on the old pack keep it, because the pack travels in their config.
134
+ */
135
+ export declare const withdrawPack: (options: {
136
+ readonly store: ContentStore;
137
+ readonly publisher: PackPublisher | null;
138
+ readonly gameId: string;
139
+ readonly slug: string;
140
+ readonly reason: WithdrawReason;
141
+ readonly actor: string;
142
+ readonly note?: string;
143
+ readonly now?: number;
144
+ }) => ItemRow | null;
package/dist/packs.js ADDED
@@ -0,0 +1,466 @@
1
+ import { withPackBank } from "./banks.js";
2
+ import { contentHash } from "./hash.js";
3
+ import { silentLogger } from "./log.js";
4
+ import { SCREEN_PROMPT_VERSION, modelScreen } from "./screen.js";
5
+ import { decodeConfig, defaultSleep, issuesOf, outcomeOf, recording } from "./pipeline.js";
6
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
7
+ import { utcDate } from "@joystyk/daily";
8
+ import { join } from "node:path";
9
+ //#region src/packs.ts
10
+ var PACK_SCHEMA_VERSION = 1;
11
+ var packItemId = (gameId, slug) => `${gameId}:pack:${slug}`;
12
+ /** The slug of a pack item id, or null when the id is not a pack's. */
13
+ var packSlugOf = (gameId, itemId) => {
14
+ const prefix = `${gameId}:pack:`;
15
+ return itemId.startsWith(prefix) ? itemId.slice(prefix.length) : null;
16
+ };
17
+ var counter = 0;
18
+ var writeAtomic = (path, body) => {
19
+ const temporary = `${path}.${String(process.pid)}.${String(++counter)}.tmp`;
20
+ writeFileSync(temporary, body);
21
+ renameSync(temporary, path);
22
+ };
23
+ /** The pack file body for a config: the envelope, as compact JSON. */
24
+ var packFileBody = (gameId, slug, config) => {
25
+ const hash = contentHash(config);
26
+ const file = {
27
+ schemaVersion: 1,
28
+ itemId: packItemId(gameId, slug),
29
+ slug,
30
+ contentHash: hash,
31
+ config
32
+ };
33
+ return {
34
+ body: JSON.stringify(file),
35
+ hash
36
+ };
37
+ };
38
+ /** Pack files under `<dir>/<game>/packs/`, written atomically; a file named by its hash is never rewritten. */
39
+ var createPackPublisher = (dir) => ({
40
+ dir,
41
+ writePack: (gameId, slug, config) => {
42
+ const { body, hash } = packFileBody(gameId, slug, config);
43
+ const folder = join(dir, gameId, "packs");
44
+ mkdirSync(folder, { recursive: true });
45
+ const name = `${slug}.${hash}.json`;
46
+ const path = join(folder, name);
47
+ if (!existsSync(path)) writeAtomic(path, body);
48
+ return {
49
+ hash,
50
+ size: Buffer.byteLength(body),
51
+ file: `${gameId}/packs/${name}`
52
+ };
53
+ },
54
+ writeIndex: (gameId, packs) => {
55
+ const folder = join(dir, gameId, "packs");
56
+ mkdirSync(folder, { recursive: true });
57
+ const index = {
58
+ schemaVersion: 1,
59
+ gameId,
60
+ packs: [...packs].sort((a, b) => a.slug < b.slug ? -1 : 1)
61
+ };
62
+ writeAtomic(join(folder, "index.json"), `${JSON.stringify(index)}\n`);
63
+ }
64
+ });
65
+ /** The current published packs of a game, from the store. */
66
+ var publishedPacks = (store, gameId) => store.items(gameId, "0000-01-01", "9999-12-31").filter((row) => row.status === "published" && packSlugOf(gameId, row.puzzleId) !== null);
67
+ /** Rewrites a game's pack index from the store: every current published pack. */
68
+ var rebuildPackIndex = (store, publisher, gameId) => {
69
+ publisher.writeIndex(gameId, publishedPacks(store, gameId).flatMap((row) => {
70
+ const slug = packSlugOf(gameId, row.puzzleId);
71
+ return slug === null || row.contentHash === null || row.size === null ? [] : [{
72
+ slug,
73
+ hash: row.contentHash,
74
+ size: row.size
75
+ }];
76
+ }));
77
+ };
78
+ var done = (row) => row !== null && (row.status === "validated" || row.status === "published");
79
+ var runPackPipeline = async (options) => {
80
+ const { store, filter, publisher } = options;
81
+ const now = options.now ?? Date.now;
82
+ const log = options.log ?? silentLogger;
83
+ const sleep = options.sleep ?? defaultSleep;
84
+ const maxAttempts = options.maxAttempts ?? 3;
85
+ const breakerThreshold = options.breakerThreshold ?? 5;
86
+ const backoffMs = options.backoffMs ?? 2e3;
87
+ const screenOn = options.screen ?? true;
88
+ const signal = options.signal;
89
+ const generators = options.generators.filter((g) => options.games === void 0 || options.games.includes(g.gameId)).map((generator) => withPackBank(store, generator));
90
+ const runId = store.startRun(options.trigger ?? "manual-packs", now());
91
+ log.info("pack_run_started", {
92
+ runId,
93
+ games: generators.map((g) => g.gameId),
94
+ model: options.model?.model ?? null,
95
+ dryRun: publisher === null
96
+ });
97
+ let consecutiveEndpointErrors = 0;
98
+ let breakerOpen = false;
99
+ let aborted = false;
100
+ const slots = [];
101
+ const published = [];
102
+ const report = (generator, slot, row, action, attempts) => ({
103
+ gameId: generator.gameId,
104
+ slug: slot.slug,
105
+ itemId: packItemId(generator.gameId, slot.slug),
106
+ action,
107
+ status: row?.status ?? "none",
108
+ source: row?.source ?? null,
109
+ attempts,
110
+ contentHash: row?.contentHash ?? null,
111
+ config: row?.config ?? null
112
+ });
113
+ /** The schema, the game validators and the word lists. */
114
+ const judge = async (generator, value, itemId, slot, inputs) => {
115
+ const decoded = await decodeConfig(generator.configSchema, value);
116
+ if (!decoded.ok) return {
117
+ outcome: "schema",
118
+ issues: decoded.error,
119
+ config: null,
120
+ filter: null
121
+ };
122
+ const config = decoded.value;
123
+ const issues = [...generator.validate(config, {
124
+ itemId,
125
+ slug: slot.slug,
126
+ inputs
127
+ })];
128
+ if (issues.length > 0) return {
129
+ outcome: "invalid",
130
+ issues,
131
+ config,
132
+ filter: null
133
+ };
134
+ const filtered = filter.check(generator.texts(config));
135
+ if (!filtered.ok) {
136
+ const words = [...new Set(filtered.hits.map((hit) => hit.term))].join(", ");
137
+ return {
138
+ outcome: "filtered",
139
+ issues: [`Text in ${[...new Set(filtered.hits.map((hit) => hit.path))].join(", ")} touches a topic or word that isn't allowed (${words}).`],
140
+ config,
141
+ filter: filtered
142
+ };
143
+ }
144
+ return {
145
+ outcome: "validated",
146
+ issues: [],
147
+ config,
148
+ filter: filtered
149
+ };
150
+ };
151
+ /** Generates with retries; the validated config, or null with every attempt reported. */
152
+ const generate = async (generator, slot, itemId, model, attempts) => {
153
+ let feedback = [];
154
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
155
+ if (signal?.aborted === true) {
156
+ aborted = true;
157
+ return null;
158
+ }
159
+ if (attempt > 1) await sleep(backoffMs * 2 ** (attempt - 2), signal);
160
+ const calls = [];
161
+ const recorded = recording(model, calls);
162
+ const finish = (result) => {
163
+ attempts.push({
164
+ attempt,
165
+ calls,
166
+ ...result
167
+ });
168
+ log.info("pack_attempt", {
169
+ itemId,
170
+ attempt,
171
+ outcome: result.outcome,
172
+ issues: result.issues.slice(0, 8),
173
+ calls: calls.map((c) => ({
174
+ name: c.name,
175
+ ok: c.ok,
176
+ ms: c.latencyMs,
177
+ tokens: c.totalTokens
178
+ }))
179
+ });
180
+ };
181
+ const generated = await generator.generate({
182
+ itemId,
183
+ slug: slot.slug,
184
+ inputs: slot.inputs,
185
+ seed: itemId,
186
+ attempt,
187
+ feedback,
188
+ model: recorded,
189
+ ...signal === void 0 ? {} : { signal }
190
+ });
191
+ if (!generated.ok) {
192
+ const failure = generated.error;
193
+ const outcome = failure.kind === "model" ? outcomeOf(failure.error) : "rejected";
194
+ const issues = failure.kind === "model" ? issuesOf(failure.error) : [...failure.issues];
195
+ finish({
196
+ outcome,
197
+ issues,
198
+ filter: null,
199
+ screen: null
200
+ });
201
+ if (outcome === "aborted") {
202
+ aborted = true;
203
+ return null;
204
+ }
205
+ if (outcome === "endpoint") {
206
+ if (++consecutiveEndpointErrors >= breakerThreshold) {
207
+ breakerOpen = true;
208
+ log.error("circuit_breaker_open", { consecutiveEndpointErrors });
209
+ return null;
210
+ }
211
+ } else {
212
+ consecutiveEndpointErrors = 0;
213
+ feedback = issues;
214
+ }
215
+ continue;
216
+ }
217
+ consecutiveEndpointErrors = 0;
218
+ const verdict = await judge(generator, generated.value, itemId, slot, slot.inputs);
219
+ if (verdict.outcome !== "validated" || verdict.config === null) {
220
+ finish({
221
+ outcome: verdict.outcome,
222
+ issues: verdict.issues,
223
+ filter: verdict.filter,
224
+ screen: null
225
+ });
226
+ feedback = verdict.issues;
227
+ continue;
228
+ }
229
+ let screen = null;
230
+ if (screenOn) {
231
+ const screened = await modelScreen(recorded, generator.texts(verdict.config), signal);
232
+ if (!screened.ok) {
233
+ const outcome = outcomeOf(screened.error);
234
+ finish({
235
+ outcome,
236
+ issues: issuesOf(screened.error),
237
+ filter: verdict.filter,
238
+ screen: null
239
+ });
240
+ if (outcome === "aborted") {
241
+ aborted = true;
242
+ return null;
243
+ }
244
+ if (outcome === "endpoint" && ++consecutiveEndpointErrors >= breakerThreshold) {
245
+ breakerOpen = true;
246
+ return null;
247
+ }
248
+ continue;
249
+ }
250
+ screen = {
251
+ safe: screened.value.verdict.safe,
252
+ reasons: screened.value.verdict.reasons
253
+ };
254
+ if (!screen.safe) {
255
+ const reasons = screen.reasons.length > 0 ? screen.reasons : ["The safety screen refused the text."];
256
+ finish({
257
+ outcome: "screened",
258
+ issues: reasons.map((reason) => `Safety screen: ${reason}`),
259
+ filter: verdict.filter,
260
+ screen
261
+ });
262
+ feedback = reasons.map((reason) => `A safety reviewer objected: ${reason}`);
263
+ continue;
264
+ }
265
+ }
266
+ finish({
267
+ outcome: "validated",
268
+ issues: [],
269
+ filter: verdict.filter,
270
+ screen
271
+ });
272
+ return verdict.config;
273
+ }
274
+ return null;
275
+ };
276
+ const fillSlot = async (generator, slot) => {
277
+ const itemId = packItemId(generator.gameId, slot.slug);
278
+ let row = store.current(itemId);
279
+ const holdsBank = done(row) && row?.source === "bank";
280
+ if (done(row) && !holdsBank) return report(generator, slot, row, "existing", []);
281
+ const attempts = [];
282
+ const model = options.model;
283
+ const history = (base) => [...base?.report ?? [], ...attempts.map((a) => a)];
284
+ if (model !== null && !breakerOpen) {
285
+ if (row === null) row = store.createItem({
286
+ gameId: generator.gameId,
287
+ puzzleId: itemId,
288
+ date: utcDate(now()),
289
+ inputs: slot.inputs,
290
+ seed: itemId,
291
+ answer: null,
292
+ now: now()
293
+ });
294
+ if (!holdsBank) row = store.updateItem(row.id, {
295
+ status: "generating",
296
+ model: model.model,
297
+ promptVersion: generator.promptVersion
298
+ }, now());
299
+ const config = await generate(generator, slot, itemId, model, attempts);
300
+ if (config !== null) {
301
+ let action = "generated";
302
+ if (holdsBank) {
303
+ store.withdraw(itemId, "other", "content", now(), "Replaced by a generated pack.");
304
+ row = store.createItem({
305
+ gameId: generator.gameId,
306
+ puzzleId: itemId,
307
+ date: utcDate(now()),
308
+ inputs: slot.inputs,
309
+ seed: itemId,
310
+ answer: null,
311
+ now: now()
312
+ });
313
+ action = "replaced-bank";
314
+ }
315
+ row = store.updateItem(row.id, {
316
+ status: "validated",
317
+ source: "generated",
318
+ model: model.model,
319
+ promptVersion: generator.promptVersion,
320
+ config,
321
+ contentHash: contentHash(config),
322
+ attempts: row.attempts + attempts.length,
323
+ report: history(row)
324
+ }, now(), {
325
+ kind: "validated",
326
+ detail: {
327
+ attempt: attempts.length,
328
+ screen: screenOn ? SCREEN_PROMPT_VERSION : null
329
+ }
330
+ });
331
+ return report(generator, slot, row, action, attempts);
332
+ }
333
+ }
334
+ if (holdsBank) return report(generator, slot, row, aborted ? "aborted" : "existing", attempts);
335
+ if (aborted) {
336
+ if (row !== null) row = store.updateItem(row.id, {
337
+ status: "scheduled",
338
+ attempts: row.attempts + attempts.length,
339
+ report: history(row)
340
+ }, now(), { kind: "interrupted" });
341
+ return report(generator, slot, row, "aborted", attempts);
342
+ }
343
+ row ??= store.createItem({
344
+ gameId: generator.gameId,
345
+ puzzleId: itemId,
346
+ date: utcDate(now()),
347
+ inputs: slot.inputs,
348
+ seed: itemId,
349
+ answer: null,
350
+ now: now()
351
+ });
352
+ row = store.updateItem(row.id, {
353
+ status: "failed",
354
+ attempts: row.attempts + attempts.length,
355
+ report: history(row)
356
+ }, now(), attempts.length > 0 ? {
357
+ kind: "failed",
358
+ detail: { attempts: attempts.length }
359
+ } : void 0);
360
+ const bank = options.bankFallback === false ? null : generator.bank(slot, itemId);
361
+ if (bank === null) return report(generator, slot, row, "failed", attempts);
362
+ const verdict = await judge(generator, bank, itemId, slot, null);
363
+ if (verdict.outcome !== "validated" || verdict.config === null) {
364
+ log.error("bank_pack_rejected", {
365
+ itemId,
366
+ outcome: verdict.outcome,
367
+ issues: verdict.issues
368
+ });
369
+ return report(generator, slot, row, "failed", attempts);
370
+ }
371
+ log.warn("bank_used", { itemId });
372
+ row = store.updateItem(row.id, {
373
+ status: "validated",
374
+ source: "bank",
375
+ config: verdict.config,
376
+ contentHash: contentHash(verdict.config),
377
+ model: null,
378
+ promptVersion: "bank"
379
+ }, now(), { kind: "bank" });
380
+ return report(generator, slot, row, "bank", attempts);
381
+ };
382
+ for (const generator of generators) {
383
+ for (const slot of generator.slots()) {
384
+ if (options.slugs !== void 0 && !options.slugs.includes(slot.slug)) continue;
385
+ if (signal?.aborted === true) aborted = true;
386
+ if (aborted) break;
387
+ const result = await fillSlot(generator, slot);
388
+ slots.push(result);
389
+ log.info("pack_slot", {
390
+ itemId: result.itemId,
391
+ action: result.action,
392
+ status: result.status,
393
+ source: result.source,
394
+ attempts: result.attempts.length
395
+ });
396
+ }
397
+ if (aborted) break;
398
+ }
399
+ if (publisher !== null) for (const generator of generators) {
400
+ for (const slot of generator.slots()) {
401
+ const row = store.current(packItemId(generator.gameId, slot.slug));
402
+ if (row === null || row.status !== "validated" || row.config === null) continue;
403
+ const file = publisher.writePack(generator.gameId, slot.slug, row.config);
404
+ store.updateItem(row.id, {
405
+ status: "published",
406
+ contentHash: file.hash,
407
+ size: file.size,
408
+ publishedAt: new Date(now()).toISOString()
409
+ }, now(), {
410
+ kind: "published",
411
+ detail: { file: file.file }
412
+ });
413
+ published.push({
414
+ ...file,
415
+ gameId: generator.gameId,
416
+ slug: slot.slug
417
+ });
418
+ log.info("published", {
419
+ itemId: row.puzzleId,
420
+ file: file.file,
421
+ size: file.size
422
+ });
423
+ }
424
+ rebuildPackIndex(store, publisher, generator.gameId);
425
+ }
426
+ const unfinished = slots.filter((slot) => slot.status !== "validated" && slot.status !== "published");
427
+ const status = aborted ? "aborted" : unfinished.length === 0 ? "ok" : unfinished.length === slots.length ? "failed" : "partial";
428
+ const summary = {
429
+ status,
430
+ breakerOpen,
431
+ slots: slots.map((s) => ({
432
+ itemId: s.itemId,
433
+ action: s.action,
434
+ status: s.status,
435
+ source: s.source,
436
+ attempts: s.attempts.length
437
+ })),
438
+ published: published.map((p) => p.file)
439
+ };
440
+ store.finishRun(runId, status, summary, now());
441
+ log.info("pack_run_finished", {
442
+ runId,
443
+ ...summary
444
+ });
445
+ return {
446
+ runId,
447
+ status,
448
+ slots,
449
+ published,
450
+ breakerOpen
451
+ };
452
+ };
453
+ /**
454
+ * Takes a pack down (content-pipeline.md, "Corrections"): withdrawn with a reason code and
455
+ * actor, dropped from the index at once; the next run fills the slot again. Games already
456
+ * started on the old pack keep it, because the pack travels in their config.
457
+ */
458
+ var withdrawPack = (options) => {
459
+ const row = options.store.withdraw(packItemId(options.gameId, options.slug), options.reason, options.actor, options.now ?? Date.now(), options.note);
460
+ if (row !== null && options.publisher !== null) rebuildPackIndex(options.store, options.publisher, options.gameId);
461
+ return row;
462
+ };
463
+ //#endregion
464
+ export { PACK_SCHEMA_VERSION, createPackPublisher, packFileBody, packItemId, packSlugOf, rebuildPackIndex, runPackPipeline, withdrawPack };
465
+
466
+ //# sourceMappingURL=packs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"packs.js","names":[],"sources":["../src/packs.ts"],"sourcesContent":["// Non-daily items: packs keyed by a curated slot, such as a theme, instead of a date\n// (content-pipeline.md, \"Implemented\"; Letter Drop's word packs are the first). The checks\n// are the daily pipeline's: parse, game validators, the all-ages word lists, the model screen.\n//\n// slots ─▶ generate ─▶ schema ─▶ game validators ─▶ blocklist + sensitive list ─▶ model screen ─▶ validated ─▶ publish\n// ▲ │\n// └──────── up to 3 attempts per slot per run; each retry hears what was wrong ───────────┘\n// still failing and the slot has nothing published: the slot's bank pack\n//\n// Each slot holds one current item, `<game>:pack:<slug>`, that stays published until staff\n// withdraw it. A slot that fell back to its bank pack tries the model again on every run, and a\n// validated pack replaces the bank one. Published files go next to the daily files:\n//\n// <dir>/<game>/packs/index.json { schemaVersion: 1, gameId, packs: [{ slug, hash, size }] }\n// <dir>/<game>/packs/<slug>.<hash>.json { schemaVersion: 1, itemId, slug, contentHash, config }\n//\n// The site serves them at /content/<game>/index.json and /content/<game>/<slug>.<hash>.json.\nimport { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { withPackBank } from './banks.ts';\nimport type { Json, Result, StandardSchemaV1 } from '@joystyk/contracts';\nimport { utcDate } from '@joystyk/daily';\nimport type { FieldText, WordFilter } from './filter.ts';\nimport { contentHash } from './hash.ts';\nimport { silentLogger, type Logger } from './log.ts';\nimport type { ModelClient } from './model.ts';\nimport {\n decodeConfig,\n defaultSleep,\n issuesOf,\n outcomeOf,\n recording,\n type AttemptOutcome,\n type AttemptReport,\n type CallReport,\n type GenerateFailure,\n} from './pipeline.ts';\nimport type { PublishedFile } from './publish.ts';\nimport { modelScreen, SCREEN_PROMPT_VERSION } from './screen.ts';\nimport type { ContentStore, ItemRow, WithdrawReason } from './store.ts';\n\nexport const PACK_SCHEMA_VERSION = 1;\n\n/** One slot a game keeps filled, such as a theme. Slugs are stable: they name files and items. */\nexport interface PackSlot<Inputs extends Json = Json> {\n readonly slug: string;\n readonly inputs: Inputs;\n}\n\nexport interface PackGenerateContext<Inputs extends Json> {\n readonly itemId: string;\n readonly slug: string;\n readonly inputs: Inputs;\n /** Deterministic per item: the generator draws any randomness (gap positions) from it. */\n readonly seed: string;\n /** 1-based, within this run. */\n readonly attempt: number;\n /** What was wrong with the previous attempt, in words the prompt can repeat. Empty on the first. */\n readonly feedback: readonly string[];\n readonly model: ModelClient;\n readonly signal?: AbortSignal;\n}\n\nexport interface PackGenerator<Config extends Json = Json, Inputs extends Json = Json> {\n readonly gameId: string;\n /** Recorded with every item, e.g. \"letter-drop-v1\". */\n readonly promptVersion: string;\n /** The game's pack schema: every pack is decoded through it before anything else. */\n readonly configSchema: StandardSchemaV1<unknown, Config>;\n /** The curated slots to keep filled. Deterministic: no player text ever reaches a prompt. */\n slots(): readonly PackSlot<Inputs>[];\n generate(context: PackGenerateContext<Inputs>): Promise<Result<Config, GenerateFailure>>;\n /** The game validators: pure; an empty list means the pack passes. */\n validate(\n config: Config,\n context: { readonly itemId: string; readonly slug: string; readonly inputs: Inputs | null },\n ): readonly string[];\n /** Every player-visible text, for the all-ages filter and the model screen. */\n texts(config: Config): readonly FieldText[];\n /** The reviewed fallback pack for a slot, or null when the bank has none. */\n bank(slot: PackSlot<Inputs>, itemId: string): Config | null;\n}\n\nexport const packItemId = (gameId: string, slug: string): string => `${gameId}:pack:${slug}`;\n\n/** The slug of a pack item id, or null when the id is not a pack's. */\nexport const packSlugOf = (gameId: string, itemId: string): string | null => {\n const prefix = `${gameId}:pack:`;\n return itemId.startsWith(prefix) ? itemId.slice(prefix.length) : null;\n};\n\n// ---------------------------------------------------------------------------------------------\n// Publishing\n\nexport interface PackIndexEntry {\n readonly slug: string;\n readonly hash: string;\n readonly size: number;\n}\n\nexport interface PackIndex {\n readonly schemaVersion: typeof PACK_SCHEMA_VERSION;\n readonly gameId: string;\n readonly packs: readonly PackIndexEntry[];\n}\n\nexport interface PackFile {\n readonly schemaVersion: typeof PACK_SCHEMA_VERSION;\n readonly itemId: string;\n readonly slug: string;\n readonly contentHash: string;\n readonly config: Json;\n}\n\nexport interface PackPublisher {\n readonly dir: string;\n /** Writes one pack file (if it isn't there already) and returns its hash and size. */\n writePack(gameId: string, slug: string, config: Json): PublishedFile;\n /** Replaces a game's pack index. */\n writeIndex(gameId: string, packs: readonly PackIndexEntry[]): void;\n}\n\nlet counter = 0;\nconst writeAtomic = (path: string, body: string): void => {\n const temporary = `${path}.${String(process.pid)}.${String(++counter)}.tmp`;\n writeFileSync(temporary, body);\n renameSync(temporary, path);\n};\n\n/** The pack file body for a config: the envelope, as compact JSON. */\nexport const packFileBody = (gameId: string, slug: string, config: Json): { body: string; hash: string } => {\n const hash = contentHash(config);\n const file: PackFile = {\n schemaVersion: PACK_SCHEMA_VERSION,\n itemId: packItemId(gameId, slug),\n slug,\n contentHash: hash,\n config,\n };\n return { body: JSON.stringify(file), hash };\n};\n\n/** Pack files under `<dir>/<game>/packs/`, written atomically; a file named by its hash is never rewritten. */\nexport const createPackPublisher = (dir: string): PackPublisher => ({\n dir,\n writePack: (gameId, slug, config) => {\n const { body, hash } = packFileBody(gameId, slug, config);\n const folder = join(dir, gameId, 'packs');\n mkdirSync(folder, { recursive: true });\n const name = `${slug}.${hash}.json`;\n const path = join(folder, name);\n if (!existsSync(path)) writeAtomic(path, body);\n return { hash, size: Buffer.byteLength(body), file: `${gameId}/packs/${name}` };\n },\n writeIndex: (gameId, packs) => {\n const folder = join(dir, gameId, 'packs');\n mkdirSync(folder, { recursive: true });\n const index: PackIndex = {\n schemaVersion: PACK_SCHEMA_VERSION,\n gameId,\n packs: [...packs].sort((a, b) => (a.slug < b.slug ? -1 : 1)),\n };\n writeAtomic(join(folder, 'index.json'), `${JSON.stringify(index)}\\n`);\n },\n});\n\n/** The current published packs of a game, from the store. */\nconst publishedPacks = (store: ContentStore, gameId: string): ItemRow[] =>\n store\n .items(gameId, '0000-01-01', '9999-12-31')\n .filter((row) => row.status === 'published' && packSlugOf(gameId, row.puzzleId) !== null);\n\n/** Rewrites a game's pack index from the store: every current published pack. */\nexport const rebuildPackIndex = (store: ContentStore, publisher: PackPublisher, gameId: string): void => {\n publisher.writeIndex(\n gameId,\n publishedPacks(store, gameId).flatMap((row) => {\n const slug = packSlugOf(gameId, row.puzzleId);\n return slug === null || row.contentHash === null || row.size === null\n ? []\n : [{ slug, hash: row.contentHash, size: row.size }];\n }),\n );\n};\n\n// ---------------------------------------------------------------------------------------------\n// The run\n\nexport type PackAction = 'existing' | 'generated' | 'replaced-bank' | 'bank' | 'failed' | 'aborted';\n\nexport interface PackSlotReport {\n readonly gameId: string;\n readonly slug: string;\n readonly itemId: string;\n readonly action: PackAction;\n readonly status: string;\n readonly source: string | null;\n readonly attempts: readonly AttemptReport[];\n readonly contentHash: string | null;\n readonly config: Json | null;\n}\n\nexport interface PackRunReport {\n readonly runId: number;\n readonly status: 'ok' | 'partial' | 'failed' | 'aborted';\n readonly slots: readonly PackSlotReport[];\n readonly published: readonly (PublishedFile & { readonly gameId: string; readonly slug: string })[];\n readonly breakerOpen: boolean;\n}\n\nexport interface PackPipelineOptions {\n readonly store: ContentStore;\n readonly generators: readonly PackGenerator[];\n /** Null runs without a model: validated packs publish and empty slots take bank packs. */\n readonly model: ModelClient | null;\n readonly filter: WordFilter;\n /** Null for a dry run: nothing is written. */\n readonly publisher: PackPublisher | null;\n readonly now?: () => number;\n /** Only these games, and only these slugs. */\n readonly games?: readonly string[];\n readonly slugs?: readonly string[];\n /** Publish a failed slot's bank pack (default true). `content bank`, which grows the banks, turns it off. */\n readonly bankFallback?: boolean;\n readonly maxAttempts?: number;\n readonly breakerThreshold?: number;\n /** Run the model screen (layer 3). Default true. */\n readonly screen?: boolean;\n readonly backoffMs?: number;\n readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;\n readonly signal?: AbortSignal;\n readonly log?: Logger;\n readonly trigger?: string;\n}\n\nconst done = (row: ItemRow | null): boolean =>\n row !== null && (row.status === 'validated' || row.status === 'published');\n\nexport const runPackPipeline = async (options: PackPipelineOptions): Promise<PackRunReport> => {\n const { store, filter, publisher } = options;\n const now = options.now ?? Date.now;\n const log = options.log ?? silentLogger;\n const sleep = options.sleep ?? defaultSleep;\n const maxAttempts = options.maxAttempts ?? 3;\n const breakerThreshold = options.breakerThreshold ?? 5;\n const backoffMs = options.backoffMs ?? 2000;\n const screenOn = options.screen ?? true;\n const signal = options.signal;\n const generators = options.generators\n .filter((g) => options.games === undefined || options.games.includes(g.gameId))\n .map((generator) => withPackBank(store, generator));\n const runId = store.startRun(options.trigger ?? 'manual-packs', now());\n log.info('pack_run_started', {\n runId,\n games: generators.map((g) => g.gameId),\n model: options.model?.model ?? null,\n dryRun: publisher === null,\n });\n\n let consecutiveEndpointErrors = 0;\n let breakerOpen = false;\n let aborted = false;\n const slots: PackSlotReport[] = [];\n const published: PackRunReport['published'][number][] = [];\n\n const report = (\n generator: PackGenerator,\n slot: PackSlot,\n row: ItemRow | null,\n action: PackAction,\n attempts: readonly AttemptReport[],\n ): PackSlotReport => ({\n gameId: generator.gameId,\n slug: slot.slug,\n itemId: packItemId(generator.gameId, slot.slug),\n action,\n status: row?.status ?? 'none',\n source: row?.source ?? null,\n attempts,\n contentHash: row?.contentHash ?? null,\n config: row?.config ?? null,\n });\n\n /** The schema, the game validators and the word lists. */\n const judge = async (\n generator: PackGenerator,\n value: unknown,\n itemId: string,\n slot: PackSlot,\n inputs: Json | null,\n ) => {\n const decoded = await decodeConfig(generator.configSchema, value);\n if (!decoded.ok) return { outcome: 'schema' as AttemptOutcome, issues: decoded.error, config: null, filter: null };\n const config = decoded.value;\n const issues = [...generator.validate(config, { itemId, slug: slot.slug, inputs })];\n if (issues.length > 0) return { outcome: 'invalid' as AttemptOutcome, issues, config, filter: null };\n const filtered = filter.check(generator.texts(config));\n if (!filtered.ok) {\n const words = [...new Set(filtered.hits.map((hit) => hit.term))].join(', ');\n return {\n outcome: 'filtered' as AttemptOutcome,\n issues: [\n `Text in ${[...new Set(filtered.hits.map((hit) => hit.path))].join(', ')} touches a topic or word that isn't allowed (${words}).`,\n ],\n config,\n filter: filtered,\n };\n }\n return { outcome: 'validated' as AttemptOutcome, issues: [] as string[], config, filter: filtered };\n };\n\n /** Generates with retries; the validated config, or null with every attempt reported. */\n const generate = async (\n generator: PackGenerator,\n slot: PackSlot,\n itemId: string,\n model: ModelClient,\n attempts: AttemptReport[],\n ): Promise<Json | null> => {\n let feedback: string[] = [];\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n if (signal?.aborted === true) {\n aborted = true;\n return null;\n }\n if (attempt > 1) await sleep(backoffMs * 2 ** (attempt - 2), signal);\n const calls: CallReport[] = [];\n const recorded = recording(model, calls);\n const finish = (result: Omit<AttemptReport, 'attempt' | 'calls'>) => {\n attempts.push({ attempt, calls, ...result });\n log.info('pack_attempt', {\n itemId,\n attempt,\n outcome: result.outcome,\n issues: result.issues.slice(0, 8),\n calls: calls.map((c) => ({ name: c.name, ok: c.ok, ms: c.latencyMs, tokens: c.totalTokens })),\n });\n };\n const generated = await generator.generate({\n itemId,\n slug: slot.slug,\n inputs: slot.inputs,\n seed: itemId,\n attempt,\n feedback,\n model: recorded,\n ...(signal === undefined ? {} : { signal }),\n });\n if (!generated.ok) {\n const failure = generated.error;\n const outcome = failure.kind === 'model' ? outcomeOf(failure.error) : 'rejected';\n const issues = failure.kind === 'model' ? issuesOf(failure.error) : [...failure.issues];\n finish({ outcome, issues, filter: null, screen: null });\n if (outcome === 'aborted') {\n aborted = true;\n return null;\n }\n if (outcome === 'endpoint') {\n if (++consecutiveEndpointErrors >= breakerThreshold) {\n breakerOpen = true;\n log.error('circuit_breaker_open', { consecutiveEndpointErrors });\n return null;\n }\n } else {\n consecutiveEndpointErrors = 0;\n feedback = issues;\n }\n continue;\n }\n consecutiveEndpointErrors = 0;\n const verdict = await judge(generator, generated.value, itemId, slot, slot.inputs);\n if (verdict.outcome !== 'validated' || verdict.config === null) {\n finish({ outcome: verdict.outcome, issues: verdict.issues, filter: verdict.filter, screen: null });\n feedback = verdict.issues;\n continue;\n }\n let screen: AttemptReport['screen'] = null;\n if (screenOn) {\n const screened = await modelScreen(recorded, generator.texts(verdict.config), signal);\n if (!screened.ok) {\n const outcome = outcomeOf(screened.error);\n finish({ outcome, issues: issuesOf(screened.error), filter: verdict.filter, screen: null });\n if (outcome === 'aborted') {\n aborted = true;\n return null;\n }\n if (outcome === 'endpoint' && ++consecutiveEndpointErrors >= breakerThreshold) {\n breakerOpen = true;\n return null;\n }\n continue;\n }\n screen = { safe: screened.value.verdict.safe, reasons: screened.value.verdict.reasons };\n if (!screen.safe) {\n const reasons = screen.reasons.length > 0 ? screen.reasons : ['The safety screen refused the text.'];\n finish({\n outcome: 'screened',\n issues: reasons.map((reason) => `Safety screen: ${reason}`),\n filter: verdict.filter,\n screen,\n });\n feedback = reasons.map((reason) => `A safety reviewer objected: ${reason}`);\n continue;\n }\n }\n finish({ outcome: 'validated', issues: [], filter: verdict.filter, screen });\n return verdict.config;\n }\n return null;\n };\n\n const fillSlot = async (generator: PackGenerator, slot: PackSlot): Promise<PackSlotReport> => {\n const itemId = packItemId(generator.gameId, slot.slug);\n let row = store.current(itemId);\n const holdsBank = done(row) && row?.source === 'bank';\n if (done(row) && !holdsBank) return report(generator, slot, row, 'existing', []);\n const attempts: AttemptReport[] = [];\n const model = options.model;\n const history = (base: ItemRow | null) => [\n ...((base?.report as Json[] | null) ?? []),\n ...attempts.map((a) => a as unknown as Json),\n ];\n\n if (model !== null && !breakerOpen) {\n if (row === null)\n row = store.createItem({\n gameId: generator.gameId,\n puzzleId: itemId,\n date: utcDate(now()),\n inputs: slot.inputs,\n seed: itemId,\n answer: null,\n now: now(),\n });\n if (!holdsBank)\n row = store.updateItem(\n row.id,\n { status: 'generating', model: model.model, promptVersion: generator.promptVersion },\n now(),\n );\n const config = await generate(generator, slot, itemId, model, attempts);\n if (config !== null) {\n let action: PackAction = 'generated';\n if (holdsBank) {\n // A generated pack replaces the bank pack: the bank item steps aside, recorded as such.\n store.withdraw(itemId, 'other', 'content', now(), 'Replaced by a generated pack.');\n row = store.createItem({\n gameId: generator.gameId,\n puzzleId: itemId,\n date: utcDate(now()),\n inputs: slot.inputs,\n seed: itemId,\n answer: null,\n now: now(),\n });\n action = 'replaced-bank';\n }\n row = store.updateItem(\n row.id,\n {\n status: 'validated',\n source: 'generated',\n model: model.model,\n promptVersion: generator.promptVersion,\n config,\n contentHash: contentHash(config),\n attempts: row.attempts + attempts.length,\n report: history(row),\n },\n now(),\n { kind: 'validated', detail: { attempt: attempts.length, screen: screenOn ? SCREEN_PROMPT_VERSION : null } },\n );\n return report(generator, slot, row, action, attempts);\n }\n }\n if (holdsBank) return report(generator, slot, row, aborted ? 'aborted' : 'existing', attempts);\n if (aborted) {\n if (row !== null)\n row = store.updateItem(\n row.id,\n { status: 'scheduled', attempts: row.attempts + attempts.length, report: history(row) },\n now(),\n { kind: 'interrupted' },\n );\n return report(generator, slot, row, 'aborted', attempts);\n }\n row ??= store.createItem({\n gameId: generator.gameId,\n puzzleId: itemId,\n date: utcDate(now()),\n inputs: slot.inputs,\n seed: itemId,\n answer: null,\n now: now(),\n });\n row = store.updateItem(\n row.id,\n { status: 'failed', attempts: row.attempts + attempts.length, report: history(row) },\n now(),\n attempts.length > 0 ? { kind: 'failed', detail: { attempts: attempts.length } } : undefined,\n );\n const bank = options.bankFallback === false ? null : generator.bank(slot, itemId);\n if (bank === null) return report(generator, slot, row, 'failed', attempts);\n const verdict = await judge(generator, bank, itemId, slot, null);\n if (verdict.outcome !== 'validated' || verdict.config === null) {\n log.error('bank_pack_rejected', { itemId, outcome: verdict.outcome, issues: verdict.issues });\n return report(generator, slot, row, 'failed', attempts);\n }\n log.warn('bank_used', { itemId });\n row = store.updateItem(\n row.id,\n {\n status: 'validated',\n source: 'bank',\n config: verdict.config,\n contentHash: contentHash(verdict.config),\n model: null,\n promptVersion: 'bank',\n },\n now(),\n { kind: 'bank' },\n );\n return report(generator, slot, row, 'bank', attempts);\n };\n\n for (const generator of generators) {\n for (const slot of generator.slots()) {\n if (options.slugs !== undefined && !options.slugs.includes(slot.slug)) continue;\n if (signal?.aborted === true) aborted = true;\n if (aborted) break;\n const result = await fillSlot(generator, slot);\n slots.push(result);\n log.info('pack_slot', {\n itemId: result.itemId,\n action: result.action,\n status: result.status,\n source: result.source,\n attempts: result.attempts.length,\n });\n }\n if (aborted) break;\n }\n\n if (publisher !== null) {\n for (const generator of generators) {\n for (const slot of generator.slots()) {\n const row = store.current(packItemId(generator.gameId, slot.slug));\n if (row === null || row.status !== 'validated' || row.config === null) continue;\n const file = publisher.writePack(generator.gameId, slot.slug, row.config);\n store.updateItem(\n row.id,\n { status: 'published', contentHash: file.hash, size: file.size, publishedAt: new Date(now()).toISOString() },\n now(),\n { kind: 'published', detail: { file: file.file } },\n );\n published.push({ ...file, gameId: generator.gameId, slug: slot.slug });\n log.info('published', { itemId: row.puzzleId, file: file.file, size: file.size });\n }\n rebuildPackIndex(store, publisher, generator.gameId);\n }\n }\n\n const unfinished = slots.filter((slot) => slot.status !== 'validated' && slot.status !== 'published');\n const status: PackRunReport['status'] = aborted\n ? 'aborted'\n : unfinished.length === 0\n ? 'ok'\n : unfinished.length === slots.length\n ? 'failed'\n : 'partial';\n const summary = {\n status,\n breakerOpen,\n slots: slots.map((s) => ({\n itemId: s.itemId,\n action: s.action,\n status: s.status,\n source: s.source,\n attempts: s.attempts.length,\n })),\n published: published.map((p) => p.file),\n };\n store.finishRun(runId, status, summary, now());\n log.info('pack_run_finished', { runId, ...summary });\n return { runId, status, slots, published, breakerOpen };\n};\n\n/**\n * Takes a pack down (content-pipeline.md, \"Corrections\"): withdrawn with a reason code and\n * actor, dropped from the index at once; the next run fills the slot again. Games already\n * started on the old pack keep it, because the pack travels in their config.\n */\nexport const withdrawPack = (options: {\n readonly store: ContentStore;\n readonly publisher: PackPublisher | null;\n readonly gameId: string;\n readonly slug: string;\n readonly reason: WithdrawReason;\n readonly actor: string;\n readonly note?: string;\n readonly now?: number;\n}): ItemRow | null => {\n const row = options.store.withdraw(\n packItemId(options.gameId, options.slug),\n options.reason,\n options.actor,\n options.now ?? Date.now(),\n options.note,\n );\n if (row !== null && options.publisher !== null) rebuildPackIndex(options.store, options.publisher, options.gameId);\n return row;\n};\n"],"mappings":"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"}