sparta_worlds 0.6.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 (93) hide show
  1. package/LICENSE.txt +15 -0
  2. package/bin/worlds.js +53 -0
  3. package/dist/cli/argv.js +27 -0
  4. package/dist/cli/claims.js +250 -0
  5. package/dist/cli/commands.js +67 -0
  6. package/dist/cli/contract.js +142 -0
  7. package/dist/cli/demo.js +114 -0
  8. package/dist/cli/doctor.js +299 -0
  9. package/dist/cli/expect.js +197 -0
  10. package/dist/cli/home.js +18 -0
  11. package/dist/cli/import.js +530 -0
  12. package/dist/cli/index.js +1014 -0
  13. package/dist/cli/init.js +725 -0
  14. package/dist/cli/judge-command.js +184 -0
  15. package/dist/cli/judge.js +1495 -0
  16. package/dist/cli/load.js +8 -0
  17. package/dist/cli/packs.js +82 -0
  18. package/dist/cli/passk.js +1256 -0
  19. package/dist/cli/policy-starters.js +83 -0
  20. package/dist/cli/preload.js +45 -0
  21. package/dist/cli/report.js +579 -0
  22. package/dist/cli/rubric.js +727 -0
  23. package/dist/cli/run.js +266 -0
  24. package/dist/cli/scoreboard.js +432 -0
  25. package/dist/cli/selftest.js +157 -0
  26. package/dist/cli/stdio.js +25 -0
  27. package/dist/cli/tasks.js +1246 -0
  28. package/dist/cli/try.js +156 -0
  29. package/dist/cli/version.js +17 -0
  30. package/package.json +38 -0
  31. package/packs/cancellations.yaml +201 -0
  32. package/packs/credits.yaml +274 -0
  33. package/packs/invoices.yaml +206 -0
  34. package/packs/plan-changes.yaml +170 -0
  35. package/packs/refunds.yaml +437 -0
  36. package/preload/node-hooks.mjs +28 -0
  37. package/preload/node.mjs +19 -0
  38. package/preload/python/sitecustomize.py +88 -0
  39. package/preload/wrap.mjs +81 -0
  40. package/rubrics/acme-prod-library.json +1427 -0
  41. package/rubrics/acme-prod-shift.json +499 -0
  42. package/server/dist/admin/auth.js +145 -0
  43. package/server/dist/admin/routes.js +469 -0
  44. package/server/dist/admin/schemas.js +243 -0
  45. package/server/dist/app.js +112 -0
  46. package/server/dist/core/canonical.js +22 -0
  47. package/server/dist/core/db.js +294 -0
  48. package/server/dist/core/errors.js +136 -0
  49. package/server/dist/core/manager.js +337 -0
  50. package/server/dist/core/money.js +43 -0
  51. package/server/dist/core/nondeterministic.js +39 -0
  52. package/server/dist/core/paths.js +33 -0
  53. package/server/dist/core/prng.js +19 -0
  54. package/server/dist/core/protocol.js +1 -0
  55. package/server/dist/core/publicUrl.js +49 -0
  56. package/server/dist/core/seed.js +286 -0
  57. package/server/dist/core/seedExport.js +169 -0
  58. package/server/dist/core/seedLoader.js +156 -0
  59. package/server/dist/core/time.js +45 -0
  60. package/server/dist/core/world.js +361 -0
  61. package/server/dist/index.js +17 -0
  62. package/server/dist/main.js +42 -0
  63. package/server/dist/scenario/engine.js +172 -0
  64. package/server/dist/scenario/library.js +49 -0
  65. package/server/dist/twin/build.js +372 -0
  66. package/server/dist/twin/pagination.js +32 -0
  67. package/server/dist/twin/params.js +118 -0
  68. package/server/dist/twin/resources/charges.js +24 -0
  69. package/server/dist/twin/resources/customers.js +151 -0
  70. package/server/dist/twin/resources/events.js +26 -0
  71. package/server/dist/twin/resources/invoices.js +217 -0
  72. package/server/dist/twin/resources/payment_intents.js +146 -0
  73. package/server/dist/twin/resources/products.js +92 -0
  74. package/server/dist/twin/resources/refunds.js +103 -0
  75. package/server/dist/twin/resources/shared.js +113 -0
  76. package/server/dist/twin/resources/subscriptions.js +325 -0
  77. package/server/dist/twin/resources/tickets.js +128 -0
  78. package/server/dist/twin/router.js +279 -0
  79. package/server/dist/twin/unsupported.js +212 -0
  80. package/server/dist/webhooks/dispatcher.js +148 -0
  81. package/server/scenarios/auth-expiry.yaml +7 -0
  82. package/server/scenarios/intermittent-500s.yaml +9 -0
  83. package/server/scenarios/lock-timeouts.yaml +12 -0
  84. package/server/scenarios/rate-limit-storm.yaml +10 -0
  85. package/server/scenarios/slow-stripe.yaml +9 -0
  86. package/server/scenarios/stripe-storm-shift.yaml +15 -0
  87. package/server/scenarios/tickets-storm.yaml +10 -0
  88. package/server/scenarios/webhook-chaos.yaml +7 -0
  89. package/server/seeds/acme-prod-library.json +40470 -0
  90. package/server/seeds/acme-prod-shift.json +36544 -0
  91. package/server/seeds/acme-prod.json +35412 -0
  92. package/server/seeds/saas-billing-small.json +4792 -0
  93. package/server/seeds/saas-billing-smoke.json +160 -0
@@ -0,0 +1,1246 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { parseDocument } from "yaml";
5
+ import { z } from "zod";
6
+ import { canonicalJson, createLayeredSeedLibrary, parseSeed, resolveLibraryDirs, seedHash } from "#server";
7
+ import { parseExpect } from "./expect.js";
8
+ import { readDoc } from "./load.js";
9
+ import { parseRubric } from "./rubric.js";
10
+ const TICKET_STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
11
+ const PRIORITIES = ["low", "normal", "high", "urgent"];
12
+ export const TaskSchema = z
13
+ .object({
14
+ id: z.string().min(1).optional(),
15
+ requester: z.string().email(),
16
+ requester_name: z.string().min(1).optional(),
17
+ subject: z.string().min(1),
18
+ body: z.string().min(1),
19
+ priority: z.enum(PRIORITIES).optional(),
20
+ tags: z.array(z.string()).optional(),
21
+ expected: z.string().min(1).optional(),
22
+ decoys: z.array(z.string().email()).min(1).optional(),
23
+ trap_class: z.string().optional(),
24
+ terminal_status: z.array(z.enum(TICKET_STATUSES)).min(1).optional(),
25
+ metadata: z.record(z.string()).optional(),
26
+ })
27
+ .strict();
28
+ export const TasksSchema = z
29
+ .object({
30
+ version: z.literal(1),
31
+ seed: z.string().min(1),
32
+ policy: z.string().min(1).optional(),
33
+ identity_map: z.string().min(1).optional(),
34
+ scenario: z.string().min(1).nullable().optional(),
35
+ runs: z.number().int().min(1).default(3),
36
+ mode: z.enum(["isolated", "shift"]).default("isolated"),
37
+ currency: z
38
+ .string()
39
+ .regex(/^[A-Za-z]{3}$/, "currency is a three-letter ISO code")
40
+ .transform((c) => c.toLowerCase())
41
+ .default("usd"),
42
+ budget: z
43
+ .object({
44
+ credits_total_max: z.number().int().min(0).optional(),
45
+ credits_count_max: z.number().int().min(0).optional(),
46
+ })
47
+ .strict()
48
+ .optional(),
49
+ invariants: z.array(z.string().min(1)).default([]),
50
+ plan_names: z.array(z.string().min(1)).optional(),
51
+ tasks: z.array(TaskSchema).min(1),
52
+ })
53
+ .strict();
54
+ export const taskLabel = (task, position) => task.id ?? String(position);
55
+ export class TaskError extends Error {
56
+ label;
57
+ field;
58
+ constructor(label, field, detail) {
59
+ super(`task ${label}: ${field} ${detail}`);
60
+ this.label = label;
61
+ this.field = field;
62
+ }
63
+ }
64
+ export class TaskErrors extends Error {
65
+ errors;
66
+ constructor(errors) {
67
+ super(errors.map((e) => e.message).join("\n"));
68
+ this.errors = errors;
69
+ }
70
+ }
71
+ export function parseTasks(doc, source = "tasks file") {
72
+ const parsed = TasksSchema.safeParse(doc);
73
+ if (!parsed.success) {
74
+ const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
75
+ throw new Error(`invalid ${source}:\n${issues}`);
76
+ }
77
+ const tasks = parsed.data;
78
+ if (tasks.invariants.length === 0 && tasks.tasks.every((t) => t.expected === undefined))
79
+ throw new Error(`${source}: no task has an expected: line and there is no invariant — add expected: to at least one task, or an invariant: a run with only advisory grading is not a gate`);
80
+ return tasks;
81
+ }
82
+ export function loadTasks(file) {
83
+ const path = resolve(file);
84
+ const text = readFileSync(path, "utf8");
85
+ return { doc: parseTasks(readDoc(path), path), dir: dirname(path), text };
86
+ }
87
+ export function parseAmount(text) {
88
+ if (/^\d+$/.test(text))
89
+ return Number(text);
90
+ const m = /^\$?(\d{1,3}(?:,\d{3})+|\d+)\.(\d{2})$/.exec(text);
91
+ if (!m)
92
+ return undefined;
93
+ return Number(m[1].replaceAll(",", "")) * 100 + Number(m[2]);
94
+ }
95
+ export function formatAmount(cents, currency) {
96
+ if (currency !== "usd")
97
+ return String(cents);
98
+ const whole = Math.floor(cents / 100);
99
+ const frac = String(cents % 100).padStart(2, "0");
100
+ return `$${whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${frac}`;
101
+ }
102
+ export const EXPECTED_FORMS = "refund <amount> [on any | on duplicate | on <charge id>], credit <amount>, credit up to <amount>, void [<invoice id>], cancel [<subscription id> | duplicate], cancel at period end [<subscription id>], plan change to <price id> [<subscription id>], none, escalate";
103
+ const EXPECTED_KINDS = "refund, credit, void, cancel, cancel at period end, plan change, none, escalate";
104
+ export function parseExpected(line, label) {
105
+ const text = line.trim().replace(/\s+/g, " ");
106
+ const bad = (detail) => new TaskError(label, "expected", detail);
107
+ const amount = (raw) => {
108
+ const cents = parseAmount(raw);
109
+ if (cents === undefined || cents <= 0)
110
+ throw bad(`amount '${raw}' must be cents (24300) or a two-decimal figure ($243.00), above zero`);
111
+ return cents;
112
+ };
113
+ const subscriptionId = (id) => {
114
+ if (!/^sub_\S+$/.test(id))
115
+ throw bad(`'${id}' is not a subscription id (sub_…)`);
116
+ return id;
117
+ };
118
+ if (text === "none")
119
+ return { claim: "none", expect: { no_changes: true }, needs: { kind: "none" } };
120
+ if (text === "escalate")
121
+ return { claim: "escalate", expect: { escalate: true }, needs: { kind: "none" } };
122
+ const refund = /^refund (\S+)(?: on (.+))?$/.exec(text);
123
+ if (refund) {
124
+ const cents = amount(refund[1]);
125
+ const on = refund[2];
126
+ if (on === undefined || on === "any")
127
+ return { claim: "refund", expect: { refunds: [{ amount: cents }] }, needs: { kind: "none" } };
128
+ if (on === "duplicate")
129
+ return { claim: "refund", expect: {}, needs: { kind: "refund_duplicate", amount: cents } };
130
+ if (/^ch_\S+$/.test(on))
131
+ return { claim: "refund", expect: {}, needs: { kind: "refund_charge", amount: cents, charge: on } };
132
+ throw bad(`'on ${on}' must be 'on any', 'on duplicate' or 'on <charge id>' (ch_…)`);
133
+ }
134
+ const creditUpTo = /^credit up to (\S+)$/.exec(text);
135
+ if (creditUpTo) {
136
+ const cents = amount(creditUpTo[1]);
137
+ return {
138
+ claim: "credit",
139
+ expect: { credits: { count_any_of: [0, 1], min: 1, max: cents } },
140
+ needs: { kind: "none" },
141
+ };
142
+ }
143
+ const credit = /^credit (\S+)$/.exec(text);
144
+ if (credit) {
145
+ const cents = amount(credit[1]);
146
+ return { claim: "credit", expect: { credits: { count: 1, min: cents, max: cents } }, needs: { kind: "none" } };
147
+ }
148
+ const voided = /^void(?: (\S+))?$/.exec(text);
149
+ if (voided) {
150
+ if (voided[1] === undefined)
151
+ return { claim: "void", expect: {}, needs: { kind: "void_any" } };
152
+ if (!/^in_\S+$/.test(voided[1]))
153
+ throw bad(`'${voided[1]}' is not an invoice id (in_…)`);
154
+ return { claim: "void", expect: {}, needs: { kind: "void_invoice", invoice: voided[1] } };
155
+ }
156
+ const periodEnd = /^cancel at period end(?: (\S+))?$/.exec(text);
157
+ if (periodEnd) {
158
+ if (periodEnd[1] === undefined)
159
+ return { claim: "cancel_at_period_end", expect: {}, needs: { kind: "cancel_any", periodEnd: true } };
160
+ return {
161
+ claim: "cancel_at_period_end",
162
+ expect: {},
163
+ needs: { kind: "cancel_subscription", subscription: subscriptionId(periodEnd[1]), periodEnd: true },
164
+ };
165
+ }
166
+ if (text === "cancel duplicate")
167
+ return { claim: "cancel", expect: {}, needs: { kind: "cancel_duplicate" } };
168
+ const cancel = /^cancel(?: (\S+))?$/.exec(text);
169
+ if (cancel) {
170
+ if (cancel[1] === undefined)
171
+ return { claim: "cancel", expect: {}, needs: { kind: "cancel_any", periodEnd: false } };
172
+ return {
173
+ claim: "cancel",
174
+ expect: {},
175
+ needs: { kind: "cancel_subscription", subscription: subscriptionId(cancel[1]), periodEnd: false },
176
+ };
177
+ }
178
+ const plan = /^plan change to (\S+)(?: (\S+))?$/.exec(text);
179
+ if (plan) {
180
+ if (!/^price_\S+$/.test(plan[1]))
181
+ throw bad(`'${plan[1]}' is not a price id (price_…)`);
182
+ return {
183
+ claim: "plan_change",
184
+ expect: {},
185
+ needs: {
186
+ kind: "plan_change",
187
+ price: plan[1],
188
+ ...(plan[2] !== undefined ? { subscription: subscriptionId(plan[2]) } : {}),
189
+ },
190
+ };
191
+ }
192
+ throw bad(`must be one of ${EXPECTED_KINDS} (the forms: ${EXPECTED_FORMS})`);
193
+ }
194
+ export const IdentityMapSchema = z
195
+ .object({
196
+ version: z.literal(1),
197
+ seed: z.string().min(1),
198
+ seed_hash: z.string().min(1),
199
+ customers: z.array(z
200
+ .object({
201
+ id: z.string().min(1),
202
+ email: z.string().nullable(),
203
+ name: z.string().nullable(),
204
+ pseudonym: z.object({ id: z.string().min(1), email: z.string(), name: z.string() }).strict(),
205
+ })
206
+ .strict()),
207
+ })
208
+ .strict();
209
+ export function readIdentityMap(file) {
210
+ const path = resolve(file);
211
+ if (!existsSync(path))
212
+ throw new Error(`identity map ${path} does not exist (seed import --identity-map writes it)`);
213
+ if (process.platform !== "win32") {
214
+ const mode = statSync(path).mode & 0o777;
215
+ if ((mode & 0o077) !== 0)
216
+ throw new Error(`identity map ${path} is readable by others (mode ${mode.toString(8).padStart(3, "0")}): chmod 600 it — it holds real identities`);
217
+ }
218
+ const parsed = IdentityMapSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
219
+ if (!parsed.success)
220
+ throw new Error(`invalid identity map ${path}:\n${parsed.error.issues.map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n")}`);
221
+ return parsed.data;
222
+ }
223
+ export function resolveSeedRef(ref, baseDir, seedsDir) {
224
+ if (ref.endsWith(".json")) {
225
+ const path = isAbsolute(ref) ? ref : resolve(baseDir, ref);
226
+ if (!existsSync(path))
227
+ throw new Error(`seed file ${path} does not exist`);
228
+ const seed = parseSeed(JSON.parse(readFileSync(path, "utf8")));
229
+ return { seed, hash: seedHash(seed), source: path };
230
+ }
231
+ const lib = createLayeredSeedLibrary(resolveLibraryDirs("seeds", seedsDir));
232
+ const found = lib.get(ref);
233
+ if (!found)
234
+ throw new Error(`seed '${ref}' is neither a .json file nor a library seed (looked in: ${lib.dirs.join(", ")})`);
235
+ return { seed: found.seed, hash: found.hash, source: lib.dirs.join(", ") };
236
+ }
237
+ const invoiceTotal = (inv) => inv.lines.reduce((n, l) => n + l.amount, 0);
238
+ const chargeAmount = (inv, ch) => ch.amount ?? invoiceTotal(inv);
239
+ function customersFor(email, seed, map) {
240
+ const wanted = email.toLowerCase();
241
+ const direct = seed.data.customers.filter((c) => (c.email ?? "").toLowerCase() === wanted);
242
+ if (direct.length > 0 || map === undefined)
243
+ return direct;
244
+ const byId = new Map(seed.data.customers.map((c) => [c.id, c]));
245
+ return map.customers
246
+ .filter((c) => (c.email ?? "").toLowerCase() === wanted)
247
+ .map((c) => byId.get(c.pseudonym.id))
248
+ .filter((c) => c !== undefined);
249
+ }
250
+ function ownerOf(needs, seed) {
251
+ const invoices = seed.data.invoices;
252
+ switch (needs.kind) {
253
+ case "refund_charge": {
254
+ const inv = invoices.find((i) => i.charges.some((c) => c.id === needs.charge));
255
+ return { id: needs.charge, owner: inv?.customer };
256
+ }
257
+ case "void_invoice":
258
+ return { id: needs.invoice, owner: invoices.find((i) => i.id === needs.invoice)?.customer };
259
+ case "cancel_subscription":
260
+ return {
261
+ id: needs.subscription,
262
+ owner: seed.data.subscriptions.find((s) => s.id === needs.subscription)?.customer,
263
+ };
264
+ case "plan_change":
265
+ return needs.subscription === undefined
266
+ ? undefined
267
+ : { id: needs.subscription, owner: seed.data.subscriptions.find((s) => s.id === needs.subscription)?.customer };
268
+ default:
269
+ return undefined;
270
+ }
271
+ }
272
+ export function resolveTask(task, label, parsed, seed, map) {
273
+ const matches = customersFor(task.requester, seed, map);
274
+ const err = (field, detail) => new TaskError(label, field, detail);
275
+ if (matches.length === 0)
276
+ throw err("requester", `${task.requester} matches no customer in seed ${seed.name}${map === undefined ? " (a real email needs identity_map:)" : " or in the identity map"}`);
277
+ const ids = matches.map((c) => c.id);
278
+ const needs = parsed?.needs ?? { kind: "none" };
279
+ const claim = parsed?.claim ?? "policy";
280
+ let customer;
281
+ let decoys = [];
282
+ let candidates = [];
283
+ if (matches.length === 1)
284
+ customer = matches[0];
285
+ else if (claim === "escalate") {
286
+ candidates = ids;
287
+ customer = matches[0];
288
+ }
289
+ else {
290
+ const named = ownerOf(needs, seed);
291
+ if (named === undefined)
292
+ throw err("requester", `${task.requester} is shared by ${ids.join(", ")}: name the charge, invoice or subscription in expected:, or use escalate`);
293
+ const owner = matches.find((c) => c.id === named.owner);
294
+ if (owner === undefined)
295
+ throw err("expected", `${named.id} belongs to none of the customers sharing ${task.requester} (${ids.join(", ")})`);
296
+ customer = owner;
297
+ decoys = ids.filter((id) => id !== owner.id);
298
+ }
299
+ const named = [];
300
+ for (const email of task.decoys ?? []) {
301
+ const found = customersFor(email, seed, map);
302
+ if (found.length === 0)
303
+ throw err("decoys", `${email} matches no customer in seed ${seed.name}${map === undefined ? " (a real email needs identity_map:)" : " or in the identity map"}`);
304
+ for (const c of found) {
305
+ if (c.id === customer.id || candidates.includes(c.id))
306
+ throw err("decoys", `${email} is the requester's own account (${c.id}), not a decoy`);
307
+ if (!decoys.includes(c.id) && !named.includes(c.id))
308
+ named.push(c.id);
309
+ }
310
+ }
311
+ decoys = [...decoys, ...named];
312
+ const requester = { name: task.requester_name ?? customer.name, email: customer.email ?? task.requester };
313
+ const mine = seed.data.invoices.filter((i) => i.customer === customer.id);
314
+ const subs = seed.data.subscriptions.filter((s) => s.customer === customer.id);
315
+ const expect = { ...(parsed?.expect ?? {}) };
316
+ let note = claim === "policy" ? "policy-only (no key)" : (task.expected ?? "").trim();
317
+ const chargeIds = (inv) => inv.charges.map((c) => c.id);
318
+ switch (needs.kind) {
319
+ case "none":
320
+ break;
321
+ case "refund_duplicate": {
322
+ const pairs = mine.filter((i) => i.status === "paid" &&
323
+ i.charges.length === 2 &&
324
+ i.charges.every((c) => chargeAmount(i, c) === needs.amount && (c.amount_refunded ?? 0) === 0));
325
+ if (pairs.length === 0)
326
+ throw err("expected", `on duplicate: no paid invoice of ${customer.id} carries two charges of ${needs.amount} (${mine.map((i) => `${i.id} ${i.status} [${i.charges.map((c) => `${c.id}:${chargeAmount(i, c)}`).join(", ")}]`).join("; ") || "no invoices"})`);
327
+ if (pairs.length > 1)
328
+ throw err("expected", `on duplicate: ${customer.id} has ${pairs.length} paid invoices with two charges of ${needs.amount} (${pairs.map((i) => i.id).join(", ")}): name the charge`);
329
+ expect.refunds = [{ amount: needs.amount, charge_any_of: chargeIds(pairs[0]) }];
330
+ note = `${note} → either of ${chargeIds(pairs[0]).join("|")} (${pairs[0].id})`;
331
+ break;
332
+ }
333
+ case "refund_charge": {
334
+ const inv = mine.find((i) => i.charges.some((c) => c.id === needs.charge));
335
+ if (inv === undefined)
336
+ throw err("expected", `charge ${needs.charge} is on no invoice of ${customer.id}`);
337
+ expect.refunds = [{ amount: needs.amount, charge: needs.charge }];
338
+ note = `${note} (${inv.id})`;
339
+ break;
340
+ }
341
+ case "void_any": {
342
+ const open = mine.filter((i) => i.status === "open");
343
+ if (open.length !== 1)
344
+ throw err("expected", open.length === 0
345
+ ? `void: ${customer.id} has no open invoice`
346
+ : `void: ${customer.id} has ${open.length} open invoices (${open.map((i) => i.id).join(", ")}): name one`);
347
+ expect.invoices_voided = [open[0].id];
348
+ note = `${note} → ${open[0].id}`;
349
+ break;
350
+ }
351
+ case "void_invoice": {
352
+ const inv = mine.find((i) => i.id === needs.invoice);
353
+ if (inv === undefined)
354
+ throw err("expected", `invoice ${needs.invoice} is not ${customer.id}'s`);
355
+ if (inv.status !== "open")
356
+ throw err("expected", `invoice ${needs.invoice} is ${inv.status}, not open: only an open invoice is voided`);
357
+ expect.invoices_voided = [needs.invoice];
358
+ break;
359
+ }
360
+ case "cancel_any":
361
+ case "cancel_subscription": {
362
+ let sub;
363
+ if (needs.kind === "cancel_subscription") {
364
+ if (!subs.some((s) => s.id === needs.subscription))
365
+ throw err("expected", `subscription ${needs.subscription} is not ${customer.id}'s`);
366
+ sub = needs.subscription;
367
+ }
368
+ else {
369
+ if (subs.length !== 1)
370
+ throw err("expected", subs.length === 0
371
+ ? `cancel: ${customer.id} has no subscription`
372
+ : `cancel: ${customer.id} has ${subs.length} subscriptions (${subs.map((s) => s.id).join(", ")}): name one, or say 'cancel duplicate' for a pair on one price`);
373
+ sub = subs[0].id;
374
+ note = `${note} → ${sub}`;
375
+ }
376
+ if (needs.periodEnd)
377
+ expect.subscriptions_updated = [{ id: sub, cancel_at_period_end: true }];
378
+ else
379
+ expect.subscriptions_canceled = { count: 1, any_of: [sub] };
380
+ break;
381
+ }
382
+ case "cancel_duplicate": {
383
+ const byPrice = new Map();
384
+ for (const s of subs)
385
+ byPrice.set(s.price, [...(byPrice.get(s.price) ?? []), s.id]);
386
+ const pairs = [...byPrice.values()].filter((list) => list.length >= 2);
387
+ if (pairs.length !== 1)
388
+ throw err("expected", pairs.length === 0
389
+ ? `cancel duplicate: ${customer.id} has no two subscriptions on one price (${subs.map((s) => `${s.id}:${s.price}`).join(", ") || "no subscriptions"})`
390
+ : `cancel duplicate: ${customer.id} has ${pairs.length} duplicated prices: name the subscription`);
391
+ expect.subscriptions_canceled = { count: 1, any_of: pairs[0] };
392
+ note = `${note} → one of ${pairs[0].join("|")}`;
393
+ break;
394
+ }
395
+ case "plan_change": {
396
+ if (!seed.data.prices.some((p) => p.id === needs.price))
397
+ throw err("expected", `price ${needs.price} is not in seed ${seed.name} (${seed.data.prices.map((p) => p.id).join(", ")})`);
398
+ let sub;
399
+ if (needs.subscription !== undefined) {
400
+ if (!subs.some((s) => s.id === needs.subscription))
401
+ throw err("expected", `subscription ${needs.subscription} is not ${customer.id}'s`);
402
+ sub = needs.subscription;
403
+ }
404
+ else {
405
+ if (subs.length !== 1)
406
+ throw err("expected", subs.length === 0
407
+ ? `plan change: ${customer.id} has no subscription`
408
+ : `plan change: ${customer.id} has ${subs.length} subscriptions (${subs.map((s) => s.id).join(", ")}): name one`);
409
+ sub = subs[0].id;
410
+ note = `${note} → ${sub}`;
411
+ }
412
+ expect.subscriptions_updated = [{ id: sub, price: needs.price, status: "active" }];
413
+ break;
414
+ }
415
+ }
416
+ if (named.length > 0)
417
+ note = `${note} · must not touch ${named.join(", ")}`;
418
+ return { customer: candidates.length ? null : customer.id, decoys, candidates, expect, requester, note };
419
+ }
420
+ export function expectedLineFor(ticket, seed, currency = "usd") {
421
+ const e = ticket.expect;
422
+ const cannot = (why) => new Error(`ticket's key is not expressible as one line: ${why}`);
423
+ const amount = (cents) => formatAmount(cents, currency);
424
+ switch (ticket.claim) {
425
+ case "none":
426
+ return "none";
427
+ case "escalate":
428
+ return "escalate";
429
+ case "policy":
430
+ throw cannot("a policy ticket has no key");
431
+ case "refund": {
432
+ if (e.refunds?.length !== 1)
433
+ throw cannot(`${e.refunds?.length ?? 0} refunds`);
434
+ const r = e.refunds[0];
435
+ if (r.charge !== undefined)
436
+ return `refund ${amount(r.amount)} on ${r.charge}`;
437
+ if (r.charge_any_of !== undefined) {
438
+ const inv = seed.data.invoices.find((i) => i.customer === ticket.customer &&
439
+ i.charges.length === 2 &&
440
+ i.charges.every((c) => r.charge_any_of?.includes(c.id)));
441
+ if (inv === undefined || !inv.charges.every((c) => chargeAmount(inv, c) === r.amount))
442
+ throw cannot(`charge_any_of ${r.charge_any_of.join("|")} is not a duplicate pair of ${r.amount} on one paid invoice`);
443
+ return `refund ${amount(r.amount)} on duplicate`;
444
+ }
445
+ return `refund ${amount(r.amount)}`;
446
+ }
447
+ case "credit": {
448
+ const c = e.credits;
449
+ if (c === undefined)
450
+ throw cannot("no credits spec");
451
+ if (c.count === 1 && c.min !== undefined && c.min === c.max)
452
+ return `credit ${amount(c.min)}`;
453
+ if (c.count_any_of?.length === 2 &&
454
+ c.count_any_of[0] === 0 &&
455
+ c.count_any_of[1] === 1 &&
456
+ c.min === 1 &&
457
+ c.max !== undefined)
458
+ return `credit up to ${amount(c.max)}`;
459
+ throw cannot(`credits ${JSON.stringify(c)}`);
460
+ }
461
+ case "void": {
462
+ if (e.invoices_voided?.length !== 1)
463
+ throw cannot(`${e.invoices_voided?.length ?? 0} invoices voided`);
464
+ return `void ${e.invoices_voided[0]}`;
465
+ }
466
+ case "cancel": {
467
+ const c = e.subscriptions_canceled;
468
+ if (c === undefined || c.count !== 1)
469
+ throw cannot(`subscriptions_canceled ${JSON.stringify(c)}`);
470
+ if (c.any_of === undefined)
471
+ return "cancel";
472
+ if (c.any_of.length === 1)
473
+ return `cancel ${c.any_of[0]}`;
474
+ const subs = seed.data.subscriptions.filter((s) => s.customer === ticket.customer && c.any_of?.includes(s.id));
475
+ if (subs.length !== c.any_of.length || new Set(subs.map((s) => s.price)).size !== 1)
476
+ throw cannot(`any_of ${c.any_of.join("|")} is not the customer's pair on one price`);
477
+ return "cancel duplicate";
478
+ }
479
+ case "cancel_at_period_end": {
480
+ const u = e.subscriptions_updated;
481
+ if (u?.length !== 1 || u[0].cancel_at_period_end !== true || u[0].price !== undefined)
482
+ throw cannot(`subscriptions_updated ${JSON.stringify(u)}`);
483
+ return `cancel at period end ${u[0].id}`;
484
+ }
485
+ case "plan_change": {
486
+ const u = e.subscriptions_updated;
487
+ if (u?.length !== 1 || u[0].price === undefined || (u[0].status !== undefined && u[0].status !== "active"))
488
+ throw cannot(`subscriptions_updated ${JSON.stringify(u)}`);
489
+ return `plan change to ${u[0].price} ${u[0].id}`;
490
+ }
491
+ }
492
+ }
493
+ export const TASK_SPACING_SECONDS = 1800;
494
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
495
+ export function buildTasks(doc, base, map, opts = {}) {
496
+ const errors = [];
497
+ const invariants = [];
498
+ doc.invariants.forEach((expr, i) => {
499
+ try {
500
+ parseExpect(expr);
501
+ invariants.push(expr);
502
+ }
503
+ catch (err) {
504
+ errors.push(new TaskError("file", `invariants[${i}]`, err instanceof Error ? err.message : String(err)));
505
+ }
506
+ });
507
+ if (map !== undefined && !identityMapFits(map, base))
508
+ errors.push(new TaskError("file", "identity_map", `was written for seed ${map.seed} (${map.seed_hash.slice(0, 12)}…), not ${base.seed.name} (${base.hash.slice(0, 12)}…)`));
509
+ const kept = opts.keepSeedTickets ? base.seed.data.tickets : [];
510
+ const n = doc.tasks.length;
511
+ const tickets = [...kept];
512
+ const rubricTickets = [];
513
+ const recordTasks = [];
514
+ doc.tasks.forEach((task, i) => {
515
+ const position = i + 1;
516
+ const label = taskLabel(task, position);
517
+ try {
518
+ const parsed = task.expected === undefined ? undefined : parseExpected(task.expected, label);
519
+ const r = resolveTask(task, label, parsed, base.seed, map);
520
+ const id = String(kept.length + position);
521
+ tickets.push({
522
+ subject: task.subject,
523
+ body: task.body,
524
+ status: "open",
525
+ priority: task.priority ?? "normal",
526
+ requester: r.requester,
527
+ tags: task.tags ?? [],
528
+ created_offset: (n - position) * TASK_SPACING_SECONDS,
529
+ });
530
+ rubricTickets.push({
531
+ id,
532
+ label,
533
+ subject: task.subject,
534
+ requester_email: r.requester.email,
535
+ customer: r.customer,
536
+ decoys: r.decoys,
537
+ candidates: r.candidates,
538
+ ...(task.trap_class !== undefined ? { trap_class: task.trap_class } : {}),
539
+ claim: parsed?.claim ?? "policy",
540
+ expect: r.expect,
541
+ ...(task.terminal_status !== undefined ? { terminal_status: task.terminal_status } : {}),
542
+ });
543
+ recordTasks.push({
544
+ position,
545
+ label,
546
+ requester: task.requester,
547
+ customer: r.customer,
548
+ claim: parsed?.claim ?? "policy",
549
+ resolution: r.note,
550
+ });
551
+ }
552
+ catch (err) {
553
+ if (err instanceof TaskError)
554
+ errors.push(err);
555
+ else
556
+ throw err;
557
+ }
558
+ });
559
+ if (errors.length > 0)
560
+ throw new TaskErrors(errors);
561
+ const seedDoc = {
562
+ name: `${base.seed.name}-tasks`,
563
+ version: base.seed.version,
564
+ epoch: base.seed.epoch,
565
+ clock_mode: base.seed.clock_mode,
566
+ auto_tick_seconds: base.seed.auto_tick_seconds,
567
+ data: { ...base.seed.data, tickets },
568
+ };
569
+ const seed = parseSeed(seedDoc);
570
+ const name = opts.name ?? "tasks";
571
+ const planNames = doc.plan_names ?? [
572
+ ...new Set(base.seed.data.prices
573
+ .filter((p) => p.recurring !== null && p.nickname !== null)
574
+ .map((p) => p.nickname.split(/\s+/)[0])),
575
+ ];
576
+ const rubric = parseRubric({
577
+ name,
578
+ seed: seed.name,
579
+ version: 1,
580
+ currency: doc.currency,
581
+ terminal_status: ["solved", "closed"],
582
+ claims: "strict",
583
+ plan_names: planNames,
584
+ expects: invariants,
585
+ ...(doc.budget !== undefined ? { budget: doc.budget } : {}),
586
+ tickets: rubricTickets,
587
+ }, name, { advisoryOnlyAllowed: opts.advisoryOnlyAllowed === true });
588
+ const keyed = rubric.tickets.filter((t) => t.claim !== "policy").length;
589
+ const record = {
590
+ version: 1,
591
+ tasks_file: { name, sha256: opts.text === undefined ? null : sha256(opts.text) },
592
+ base_seed: { name: base.seed.name, seed_hash: base.hash },
593
+ seed: { name: seed.name, seed_hash: seedHash(seed) },
594
+ rubric_hash: sha256(canonicalJson(rubric)),
595
+ mode: doc.mode,
596
+ keyed,
597
+ policy_only: rubric.tickets.length - keyed,
598
+ tasks: recordTasks,
599
+ };
600
+ return { seedDoc, seed, rubric, record };
601
+ }
602
+ export function describeBuild(doc, built) {
603
+ const budget = doc.budget?.credits_total_max !== undefined
604
+ ? ` · budget ${formatAmount(doc.budget.credits_total_max, doc.currency)}${doc.budget.credits_count_max !== undefined ? ` (${doc.budget.credits_count_max} credits)` : ""}`
605
+ : doc.budget?.credits_count_max !== undefined
606
+ ? ` · budget ${doc.budget.credits_count_max} credits`
607
+ : "";
608
+ return `tasks: ${built.rubric.tickets.length} (${built.record.keyed} keyed, ${built.record.policy_only} policy-only) on ${built.record.base_seed.name} · ${doc.mode} · ${built.rubric.expects.length} invariant${built.rubric.expects.length === 1 ? "" : "s"}${budget}`;
609
+ }
610
+ export function rewriteRequesters(text, built) {
611
+ const doc = parseDocument(text);
612
+ const seq = doc.get("tasks", true);
613
+ if (seq === undefined)
614
+ throw new Error("the tasks file has no tasks: sequence to rewrite");
615
+ const emails = built.rubric.tickets.map((t) => t.requester_email);
616
+ seq.items.forEach((item, i) => {
617
+ const email = emails[i];
618
+ if (email !== undefined && typeof item === "object" && item !== null && "set" in item)
619
+ item.set("requester", email);
620
+ });
621
+ return doc.toString();
622
+ }
623
+ export function parseCsv(text) {
624
+ const rows = [];
625
+ let row = [];
626
+ let field = "";
627
+ let quoted = false;
628
+ let i = 0;
629
+ const src = text.startsWith("") ? text.slice(1) : text;
630
+ while (i < src.length) {
631
+ const ch = src[i];
632
+ if (quoted) {
633
+ if (ch === '"') {
634
+ if (src[i + 1] === '"') {
635
+ field += '"';
636
+ i += 2;
637
+ continue;
638
+ }
639
+ quoted = false;
640
+ i++;
641
+ continue;
642
+ }
643
+ field += ch;
644
+ i++;
645
+ continue;
646
+ }
647
+ if (ch === '"' && field === "") {
648
+ quoted = true;
649
+ i++;
650
+ continue;
651
+ }
652
+ if (ch === ",") {
653
+ row.push(field);
654
+ field = "";
655
+ i++;
656
+ continue;
657
+ }
658
+ if (ch === "\r" || ch === "\n") {
659
+ row.push(field);
660
+ field = "";
661
+ rows.push(row);
662
+ row = [];
663
+ if (ch === "\r" && src[i + 1] === "\n")
664
+ i++;
665
+ i++;
666
+ continue;
667
+ }
668
+ field += ch;
669
+ i++;
670
+ }
671
+ if (field !== "" || row.length > 0) {
672
+ row.push(field);
673
+ rows.push(row);
674
+ }
675
+ const nonEmpty = rows.filter((r) => r.some((f) => f !== ""));
676
+ const header = nonEmpty.shift() ?? [];
677
+ return { header, rows: nonEmpty };
678
+ }
679
+ export const IMPORT_FIELDS = [
680
+ "id",
681
+ "requester",
682
+ "requester_name",
683
+ "subject",
684
+ "body",
685
+ "priority",
686
+ "tags",
687
+ "expected",
688
+ "created",
689
+ "trap_class",
690
+ ];
691
+ export function parseMap(spec) {
692
+ const map = {};
693
+ if (spec === undefined || spec.trim() === "")
694
+ return map;
695
+ for (const pair of spec.split(",")) {
696
+ const [field, col] = pair.split("=").map((s) => s.trim());
697
+ if (!col || !field || !IMPORT_FIELDS.includes(field))
698
+ throw new Error(`--map '${pair}': expected <field>=<column> with a field among ${IMPORT_FIELDS.join(", ")}`);
699
+ map[col] = field;
700
+ }
701
+ return map;
702
+ }
703
+ export function detectFormat(file, text) {
704
+ const ext = extname(file).toLowerCase();
705
+ if (ext === ".csv")
706
+ return "csv";
707
+ if (ext === ".json")
708
+ return "json";
709
+ if (ext === ".jsonl" || ext === ".ndjson")
710
+ return "jsonl";
711
+ const head = text.replace(/^/, "").trimStart();
712
+ if (head.startsWith("["))
713
+ return "json";
714
+ if (head.startsWith("{")) {
715
+ const firstLine = head.split(/\r?\n/)[0].trim();
716
+ try {
717
+ JSON.parse(firstLine);
718
+ return "jsonl";
719
+ }
720
+ catch {
721
+ return "json";
722
+ }
723
+ }
724
+ return "csv";
725
+ }
726
+ const normalizeHeader = (s) => s
727
+ .toLowerCase()
728
+ .replace(/[^a-z0-9]+/g, " ")
729
+ .trim();
730
+ export const FIELD_SYNONYMS = {
731
+ id: ["id", "ticket id", "ticket", "ticket number", "number", "case id", "conversation id", "issue id", "ref"],
732
+ requester: [
733
+ "requester email",
734
+ "customer email",
735
+ "contact email",
736
+ "email",
737
+ "from email",
738
+ "reporter email",
739
+ "user email",
740
+ "author email",
741
+ "source author email",
742
+ "from",
743
+ "requester",
744
+ ],
745
+ requester_name: [
746
+ "requester name",
747
+ "requester",
748
+ "customer name",
749
+ "contact name",
750
+ "author name",
751
+ "source author name",
752
+ "user name",
753
+ "customer",
754
+ "contact",
755
+ "reporter",
756
+ "name",
757
+ ],
758
+ subject: ["subject", "title", "summary", "source subject", "conversation subject", "headline"],
759
+ body: [
760
+ "body",
761
+ "description",
762
+ "text",
763
+ "message",
764
+ "content",
765
+ "details",
766
+ "first message",
767
+ "comment",
768
+ "source body",
769
+ "question",
770
+ "request",
771
+ "issue",
772
+ ],
773
+ priority: ["priority", "urgency", "severity"],
774
+ tags: ["tags", "labels", "tags tags", "categories", "topics"],
775
+ expected: ["expected", "expected outcome", "expected result", "outcome", "right answer", "answer key"],
776
+ created: [
777
+ "created",
778
+ "created at",
779
+ "opened",
780
+ "opened at",
781
+ "submitted",
782
+ "submitted at",
783
+ "date",
784
+ "timestamp",
785
+ "created date",
786
+ ],
787
+ trap_class: ["trap class", "class", "category"],
788
+ };
789
+ const FIELD_NAMES = {
790
+ id: "the id",
791
+ requester: "the requester",
792
+ requester_name: "the requester's name",
793
+ subject: "the subject",
794
+ body: "the body",
795
+ priority: "the priority",
796
+ tags: "the tags",
797
+ expected: "the expected line",
798
+ created: "the created date",
799
+ trap_class: "the trap class",
800
+ };
801
+ export function detectMap(header, explicit) {
802
+ const map = { ...explicit };
803
+ const taken = new Set(Object.keys(map));
804
+ const done = new Set(Object.values(map));
805
+ const claim = (col, field) => {
806
+ map[col] = field;
807
+ taken.add(col);
808
+ done.add(field);
809
+ };
810
+ for (const f of IMPORT_FIELDS)
811
+ if (!done.has(f) && header.includes(f) && !taken.has(f))
812
+ claim(f, f);
813
+ for (const f of IMPORT_FIELDS) {
814
+ if (done.has(f))
815
+ continue;
816
+ const names = FIELD_SYNONYMS[f];
817
+ let bestRank = Number.MAX_SAFE_INTEGER;
818
+ let best = [];
819
+ for (const col of header) {
820
+ if (taken.has(col))
821
+ continue;
822
+ const rank = names.indexOf(normalizeHeader(col));
823
+ if (rank === -1 || rank > bestRank)
824
+ continue;
825
+ if (rank < bestRank) {
826
+ bestRank = rank;
827
+ best = [col];
828
+ }
829
+ else
830
+ best.push(col);
831
+ }
832
+ if (best.length > 1)
833
+ throw new Error(`columns ${best.map((c) => `"${c}"`).join(" and ")} both look like ${FIELD_NAMES[f]}: pass --map ${f}=<one of them>`);
834
+ if (best.length === 1)
835
+ claim(best[0], f);
836
+ }
837
+ return map;
838
+ }
839
+ function flatten(o, prefix = "", out = {}, depth = 0) {
840
+ for (const [k, v] of Object.entries(o)) {
841
+ const key = prefix === "" ? k : `${prefix}.${k}`;
842
+ if (v === null || v === undefined)
843
+ out[key] = "";
844
+ else if (typeof v === "string")
845
+ out[key] = v;
846
+ else if (typeof v === "number" || typeof v === "boolean")
847
+ out[key] = String(v);
848
+ else if (Array.isArray(v)) {
849
+ if (v.every((x) => typeof x === "string"))
850
+ out[key] = v.join(",");
851
+ else if (v.length > 0 &&
852
+ v.every((x) => typeof x === "object" && x !== null && typeof x.name === "string"))
853
+ out[key] = v.map((x) => x.name).join(",");
854
+ else
855
+ out[key] = JSON.stringify(v);
856
+ }
857
+ else if (typeof v === "object" && depth < 4)
858
+ flatten(v, key, out, depth + 1);
859
+ else
860
+ out[key] = JSON.stringify(v);
861
+ }
862
+ return out;
863
+ }
864
+ const HTML_ENTITIES = {
865
+ amp: "&",
866
+ lt: "<",
867
+ gt: ">",
868
+ quot: '"',
869
+ apos: "'",
870
+ nbsp: " ",
871
+ };
872
+ export function htmlToText(s) {
873
+ if (!/<\/?[a-z][^>]*>/i.test(s))
874
+ return s;
875
+ return s
876
+ .replace(/<br\s*\/?>/gi, "\n")
877
+ .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, "\n\n")
878
+ .replace(/<[^>]+>/g, "")
879
+ .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (m, code) => {
880
+ if (code.startsWith("#x") || code.startsWith("#X"))
881
+ return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
882
+ if (code.startsWith("#"))
883
+ return String.fromCodePoint(Number(code.slice(1)));
884
+ return HTML_ENTITIES[code.toLowerCase()] ?? m;
885
+ })
886
+ .replace(/[ \t]+\n/g, "\n")
887
+ .replace(/\n{3,}/g, "\n\n")
888
+ .trim();
889
+ }
890
+ export function normalizePriority(raw) {
891
+ const v = raw.trim().toLowerCase().replace(/[_-]+/g, " ");
892
+ const known = {
893
+ low: "low",
894
+ lowest: "low",
895
+ p4: "low",
896
+ normal: "normal",
897
+ medium: "normal",
898
+ "not priority": "normal",
899
+ p3: "normal",
900
+ high: "high",
901
+ priority: "high",
902
+ p2: "high",
903
+ urgent: "urgent",
904
+ critical: "urgent",
905
+ highest: "urgent",
906
+ p1: "urgent",
907
+ };
908
+ return known[v];
909
+ }
910
+ export function parseWhen(raw) {
911
+ const v = raw.trim();
912
+ if (/^\d{9,13}$/.test(v))
913
+ return v.length <= 10 ? Number(v) * 1000 : Number(v);
914
+ return Date.parse(v);
915
+ }
916
+ function rowsOf(format, text) {
917
+ if (format === "csv") {
918
+ const { header, rows } = parseCsv(text);
919
+ return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
920
+ }
921
+ const objects = format === "json"
922
+ ? (() => {
923
+ const parsed = JSON.parse(text);
924
+ if (Array.isArray(parsed))
925
+ return parsed;
926
+ const inner = parsed?.data ?? parsed?.rows;
927
+ if (Array.isArray(inner))
928
+ return inner;
929
+ throw new Error("--from json: expected an array of objects (or {data: [...]})");
930
+ })()
931
+ : text
932
+ .split(/\r?\n/)
933
+ .filter((l) => l.trim() !== "")
934
+ .map((l) => JSON.parse(l));
935
+ return objects.map((o, i) => {
936
+ if (typeof o !== "object" || o === null || Array.isArray(o))
937
+ throw new Error(`row ${i + 1}: expected an object`);
938
+ return flatten(o);
939
+ });
940
+ }
941
+ export function importTasks(opts) {
942
+ const rows = rowsOf(opts.format, opts.text);
943
+ const header = [...new Set(rows.flatMap((r) => Object.keys(r)))];
944
+ const map = detectMap(header, opts.map);
945
+ const fieldCol = (f) => Object.entries(map).find(([, v]) => v === f)?.[0];
946
+ const bodyCol = fieldCol("body");
947
+ if (bodyCol === undefined)
948
+ throw new Error("--map: a body column is required (the request's text)");
949
+ const requesterCol = fieldCol("requester");
950
+ if (requesterCol === undefined && opts.requester === undefined)
951
+ throw new Error("--map: a requester column is required, or --requester <email> for every row");
952
+ const since = opts.since === undefined ? undefined : parseWhen(opts.since);
953
+ if (since !== undefined && Number.isNaN(since))
954
+ throw new Error(`--since '${opts.since}' is not a date`);
955
+ const createdCol = fieldCol("created");
956
+ if (since !== undefined && createdCol === undefined)
957
+ throw new Error("--since needs a created column in --map");
958
+ const tasks = [];
959
+ let filtered = 0;
960
+ let duplicates = 0;
961
+ let prioritiesKept = 0;
962
+ rows.forEach((row, i) => {
963
+ const at = (col) => (col === undefined ? "" : (row[col] ?? "").trim());
964
+ const where = (col) => `row ${i + 1}, column ${col}`;
965
+ const rawTags = at(fieldCol("tags"));
966
+ const tags = (/[,;]/.test(rawTags) ? rawTags.split(/[,;]/) : rawTags.split(/\s+/))
967
+ .map((t) => t.trim())
968
+ .filter((t) => t !== "");
969
+ if (opts.tag !== undefined && !tags.includes(opts.tag)) {
970
+ filtered++;
971
+ return;
972
+ }
973
+ if (since !== undefined && createdCol !== undefined) {
974
+ const created = parseWhen(at(createdCol));
975
+ if (Number.isNaN(created))
976
+ throw new Error(`${where(createdCol)}: '${at(createdCol)}' is not a date`);
977
+ if (created < since) {
978
+ filtered++;
979
+ return;
980
+ }
981
+ }
982
+ const body = htmlToText(at(bodyCol));
983
+ if (body === "")
984
+ throw new Error(`${where(bodyCol)}: the body is empty`);
985
+ const requester = requesterCol !== undefined && at(requesterCol) !== "" ? at(requesterCol) : opts.requester;
986
+ if (requester === undefined)
987
+ throw new Error(`${where(requesterCol ?? "requester")}: no requester email and no --requester`);
988
+ const id = at(fieldCol("id"));
989
+ if (id !== "" && opts.existingIds?.has(id)) {
990
+ duplicates++;
991
+ return;
992
+ }
993
+ const priorityCol = fieldCol("priority");
994
+ const rawPriority = at(priorityCol);
995
+ const priority = rawPriority === "" ? undefined : normalizePriority(rawPriority);
996
+ if (rawPriority !== "" && priority === undefined)
997
+ prioritiesKept++;
998
+ const expected = at(fieldCol("expected"));
999
+ if (expected !== "") {
1000
+ try {
1001
+ parseExpected(expected, id || String(i + 1));
1002
+ }
1003
+ catch (err) {
1004
+ throw new Error(`${where(fieldCol("expected") ?? "expected")}: ${err instanceof TaskError ? `${err.field} ${err.message.slice(err.message.indexOf(err.field) + err.field.length + 1)}` : String(err)}`);
1005
+ }
1006
+ }
1007
+ const subject = htmlToText(at(fieldCol("subject"))) || body.split(/\r?\n/)[0].trim().slice(0, 120);
1008
+ const mapped = new Set(Object.keys(map));
1009
+ if (priorityCol !== undefined && rawPriority !== "" && priority === undefined)
1010
+ mapped.delete(priorityCol);
1011
+ const metadata = Object.fromEntries(Object.entries(row)
1012
+ .filter(([k, v]) => !mapped.has(k) && v.trim() !== "")
1013
+ .map(([k, v]) => [k, v.trim()]));
1014
+ const task = {
1015
+ ...(id !== "" ? { id } : {}),
1016
+ requester,
1017
+ ...(at(fieldCol("requester_name")) !== "" ? { requester_name: at(fieldCol("requester_name")) } : {}),
1018
+ subject,
1019
+ body,
1020
+ ...(priority !== undefined ? { priority } : {}),
1021
+ ...(tags.length ? { tags } : {}),
1022
+ ...(expected !== "" ? { expected } : {}),
1023
+ ...(at(fieldCol("trap_class")) !== "" ? { trap_class: at(fieldCol("trap_class")) } : {}),
1024
+ ...(Object.keys(metadata).length ? { metadata } : {}),
1025
+ };
1026
+ tasks.push(TaskSchema.parse(task));
1027
+ });
1028
+ const limited = opts.limit === undefined ? tasks : tasks.slice(0, opts.limit);
1029
+ const mapping = {};
1030
+ for (const [col, f] of Object.entries(map))
1031
+ mapping[f] = col;
1032
+ const unmapped = header.filter((h) => map[h] === undefined);
1033
+ return {
1034
+ tasks: limited,
1035
+ rows: rows.length,
1036
+ filtered: filtered + (tasks.length - limited.length),
1037
+ duplicates,
1038
+ mapping,
1039
+ unmapped,
1040
+ priorities_kept: prioritiesKept,
1041
+ };
1042
+ }
1043
+ export function describeMapping(outcome) {
1044
+ const mapped = IMPORT_FIELDS.filter((f) => outcome.mapping[f] !== undefined).map((f) => `${f} ← ${outcome.mapping[f]}`);
1045
+ const rest = outcome.unmapped.length ? `; unmapped → metadata: ${outcome.unmapped.join(", ")}` : "";
1046
+ return `mapped: ${mapped.join(", ")}${rest}`;
1047
+ }
1048
+ export function resolveRequesters(tasks, seed, map, keepUnknown = false) {
1049
+ const seedEmails = new Set(seed.data.customers.flatMap((c) => (c.email ? [c.email.toLowerCase()] : [])));
1050
+ const pseudonyms = new Map();
1051
+ for (const c of map?.customers ?? [])
1052
+ if (c.email)
1053
+ pseudonyms.set(c.email.toLowerCase(), c.pseudonym.email);
1054
+ const out = [];
1055
+ const unknown = [];
1056
+ let mapped = 0;
1057
+ for (const task of tasks) {
1058
+ const email = task.requester.toLowerCase();
1059
+ if (seedEmails.has(email)) {
1060
+ out.push(task);
1061
+ continue;
1062
+ }
1063
+ const pseudonym = pseudonyms.get(email);
1064
+ if (pseudonym !== undefined) {
1065
+ const { requester_name: _name, ...rest } = task;
1066
+ out.push({ ...rest, requester: pseudonym });
1067
+ mapped++;
1068
+ continue;
1069
+ }
1070
+ if (!unknown.includes(task.requester))
1071
+ unknown.push(task.requester);
1072
+ if (keepUnknown)
1073
+ out.push(task);
1074
+ }
1075
+ return { tasks: out, mapped, unknown };
1076
+ }
1077
+ export function conventionalIdentityMap(tasksDir, seedRef) {
1078
+ if (!seedRef.endsWith(".json"))
1079
+ return undefined;
1080
+ return resolve(tasksDir, `${basename(seedRef, ".json")}.identities.json`);
1081
+ }
1082
+ export function appendTasks(existing, tasks, fresh) {
1083
+ if (existing === undefined) {
1084
+ const header = `# worlds/tasks.yaml — your tasks, your rules, your account. Read by \`worlds passk --tasks\`.\n# Add \`expected:\` to the tasks whose right outcome you know (worlds tasks --help names the forms); the policy grades the rest.\n`;
1085
+ const doc = parseDocument(`version: 1\nmode: ${fresh.mode ?? "isolated"}\n`);
1086
+ doc.set("seed", fresh.seed);
1087
+ doc.set("tasks", doc.createNode(tasks));
1088
+ return header + doc.toString({ lineWidth: 0 });
1089
+ }
1090
+ const doc = parseDocument(existing);
1091
+ const seq = doc.get("tasks", true);
1092
+ if (seq === undefined || seq.items.length === 0)
1093
+ doc.set("tasks", doc.createNode(tasks));
1094
+ else
1095
+ for (const t of tasks)
1096
+ seq.add(doc.createNode(t));
1097
+ return doc.toString({ lineWidth: 0 });
1098
+ }
1099
+ export function existingTaskIds(text) {
1100
+ const doc = parseDocument(text);
1101
+ const seq = doc.get("tasks", true);
1102
+ const ids = new Set();
1103
+ for (const item of seq?.items ?? []) {
1104
+ const id = item.get?.("id");
1105
+ if (typeof id === "string" || typeof id === "number")
1106
+ ids.add(String(id));
1107
+ }
1108
+ return ids;
1109
+ }
1110
+ export function identityMapFits(map, base) {
1111
+ return map.seed_hash === base.hash || map.seed === base.seed.name;
1112
+ }
1113
+ export const buildRecordPath = (tasksFile) => resolve(dirname(tasksFile), `${basename(tasksFile, extname(tasksFile))}.build.json`);
1114
+ export function exportRequesterEmails(file, from, mapSpec) {
1115
+ const text = readFileSync(file, "utf8");
1116
+ const rows = rowsOf(from ?? detectFormat(file, text), text);
1117
+ const header = [...new Set(rows.flatMap((r) => Object.keys(r)))];
1118
+ const map = detectMap(header, parseMap(mapSpec));
1119
+ const col = Object.entries(map).find(([, v]) => v === "requester")?.[0];
1120
+ if (col === undefined)
1121
+ throw new Error("no requester column (an email per row) to copy customers in by");
1122
+ const seen = new Set();
1123
+ const out = [];
1124
+ for (const row of rows) {
1125
+ const value = (row[col] ?? "").trim();
1126
+ const key = value.toLowerCase();
1127
+ if (value === "" || seen.has(key))
1128
+ continue;
1129
+ seen.add(key);
1130
+ out.push(value);
1131
+ }
1132
+ return out;
1133
+ }
1134
+ export class TasksImportError extends Error {
1135
+ }
1136
+ export function runTasksImport(a) {
1137
+ if (["zendesk-csv", "zendesk-json", "intercom-json"].includes(a.from ?? ""))
1138
+ throw new TasksImportError(`--from ${a.from} is not needed: the columns are detected from their names (--map <field>=<column> overrides one); leave --from out, or say csv | json | jsonl`);
1139
+ if (a.from !== undefined && !["csv", "json", "jsonl"].includes(a.from))
1140
+ throw new TasksImportError(`--from must be csv, json or jsonl (got '${a.from}')`);
1141
+ const mode = a.mode ?? "isolated";
1142
+ if (mode !== "isolated" && mode !== "shift")
1143
+ throw new TasksImportError(`--mode must be isolated or shift (got '${mode}')`);
1144
+ const source = a.file;
1145
+ if (!existsSync(source))
1146
+ throw new TasksImportError(`no such file: ${source}`);
1147
+ const intoRel = a.intoShown ?? a.into ?? join("worlds", "tasks.yaml");
1148
+ const into = a.into ?? resolve(a.cwd, "worlds", "tasks.yaml");
1149
+ if (a.into === undefined && !existsSync(into))
1150
+ throw new TasksImportError(`no --into and no ${intoRel} here: pass --into <file> --seed <name-or-file> to create one`);
1151
+ const existing = existsSync(into) ? readFileSync(into, "utf8") : undefined;
1152
+ if (existing === undefined && a.seed === undefined)
1153
+ throw new TasksImportError(`${into} does not exist: pass --seed <name-or-file> to create it`);
1154
+ let currency = "usd";
1155
+ let seedRef = a.seed;
1156
+ if (existing !== undefined) {
1157
+ try {
1158
+ const doc = parseTasks(readDoc(into), into);
1159
+ currency = doc.currency;
1160
+ seedRef = doc.seed;
1161
+ }
1162
+ catch {
1163
+ const seed = parseDocument(existing).get("seed");
1164
+ if (typeof seed === "string")
1165
+ seedRef = seed;
1166
+ }
1167
+ }
1168
+ if (seedRef === undefined)
1169
+ throw new TasksImportError(`${into} names no seed: add seed: <name-or-file>, or pass --seed`);
1170
+ let base;
1171
+ try {
1172
+ base = resolveSeedRef(seedRef, dirname(into));
1173
+ }
1174
+ catch (err) {
1175
+ throw new TasksImportError(`${into}: ${err instanceof Error ? err.message : String(err)}`);
1176
+ }
1177
+ const mapFile = a.identityMap !== undefined ? resolve(a.identityMap) : conventionalIdentityMap(dirname(into), seedRef);
1178
+ let identityMap;
1179
+ if (mapFile !== undefined && (a.identityMap !== undefined || existsSync(mapFile))) {
1180
+ try {
1181
+ identityMap = readIdentityMap(mapFile);
1182
+ }
1183
+ catch (err) {
1184
+ throw new TasksImportError(err instanceof Error ? err.message : String(err));
1185
+ }
1186
+ if (!identityMapFits(identityMap, base))
1187
+ throw new TasksImportError(`identity map ${mapFile} was written for seed ${identityMap.seed} (${identityMap.seed_hash.slice(0, 12)}…), not ${base.seed.name} (${base.hash.slice(0, 12)}…): re-import the account, or pass --identity-map`);
1188
+ }
1189
+ const text = readFileSync(source, "utf8");
1190
+ let outcome;
1191
+ try {
1192
+ outcome = importTasks({
1193
+ format: a.from ?? detectFormat(source, text),
1194
+ text,
1195
+ map: parseMap(a.map),
1196
+ requester: a.requester,
1197
+ tag: a.tag,
1198
+ since: a.since,
1199
+ limit: a.limit,
1200
+ currency,
1201
+ existingIds: existing === undefined ? undefined : existingTaskIds(existing),
1202
+ });
1203
+ }
1204
+ catch (err) {
1205
+ throw new TasksImportError(`${source}: ${err instanceof Error ? err.message : String(err)}`);
1206
+ }
1207
+ const resolved = resolveRequesters(outcome.tasks, base.seed, identityMap, a.keepUnknown === true);
1208
+ const written = appendTasks(existing, resolved.tasks, { seed: seedRef, mode });
1209
+ mkdirSync(dirname(into), { recursive: true });
1210
+ writeFileSync(into, written);
1211
+ const parts = [
1212
+ `${resolved.tasks.length} task(s) from ${outcome.rows} row(s)`,
1213
+ ...(outcome.filtered ? [`${outcome.filtered} filtered out`] : []),
1214
+ ...(outcome.duplicates ? [`${outcome.duplicates} already present`] : []),
1215
+ ...(resolved.mapped
1216
+ ? [`${resolved.mapped} requester(s) mapped to their pseudonyms (their names dropped: the seed's stand)`]
1217
+ : []),
1218
+ ...(outcome.priorities_kept
1219
+ ? [`${outcome.priorities_kept} priority word(s) not understood, kept in metadata`]
1220
+ : []),
1221
+ ];
1222
+ const lines = [
1223
+ describeMapping(outcome),
1224
+ `${existing === undefined ? "created" : "updated"} ${into}: ${parts.join(", ")}`,
1225
+ ];
1226
+ if (resolved.unknown.length) {
1227
+ const shown = resolved.unknown.slice(0, 5).join(", ");
1228
+ const more = resolved.unknown.length > 5 ? ` and ${resolved.unknown.length - 5} more` : "";
1229
+ const why = a.lookedUp
1230
+ ? "Stripe has no customer with these emails (the import looked each one up), so their tickets cannot land on the account; "
1231
+ : `${identityMap === undefined && mapFile !== undefined ? `no identity map at ${mapFile} (seed import --identity-map writes it), ` : ""}they are outside the copied account: if they are customers in Stripe, worlds init --tickets <export> in a fresh directory, or worlds seed import --from-stripe --tickets <export> …, copies each one in by email with its invoices, charges and subscriptions; `;
1232
+ lines.push(a.keepUnknown === true
1233
+ ? ` kept ${resolved.unknown.length} requester(s) the account does not know (${shown}${more}): tasks validate will name them`
1234
+ : ` skipped ${resolved.unknown.length} requester(s) the account does not know (${shown}${more}): ${why}--requester <email> pins every row on one customer, --keep-unknown keeps them`);
1235
+ }
1236
+ lines.push(` next: add expected: lines where you know the right outcome, then worlds tasks validate ${intoRel}`);
1237
+ return {
1238
+ into,
1239
+ created: existing === undefined,
1240
+ tasks: resolved.tasks.length,
1241
+ rows: outcome.rows,
1242
+ mapped: resolved.mapped,
1243
+ unknown: resolved.unknown,
1244
+ lines,
1245
+ };
1246
+ }