@faapi/faapi 6.32.0 → 6.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2126,19 +2126,6 @@ var TaskCancelledError = class extends Error {
2126
2126
  this.name = "TaskCancelledError";
2127
2127
  }
2128
2128
  };
2129
- function safeConfig(config) {
2130
- if (config === void 0 || config === null) return config;
2131
- try {
2132
- structuredClone(config);
2133
- return config;
2134
- } catch {
2135
- }
2136
- try {
2137
- return JSON.parse(JSON.stringify(config));
2138
- } catch {
2139
- return void 0;
2140
- }
2141
- }
2142
2129
  function reviveError(payload) {
2143
2130
  const err = new Error(payload?.message ?? "task worker error");
2144
2131
  if (payload?.name !== void 0) err.name = payload.name;
@@ -2207,6 +2194,53 @@ async function runTaskInWorker(options) {
2207
2194
  worker.on(
2208
2195
  "message",
2209
2196
  (msg) => {
2197
+ if (msg?.type === "tasks-call") {
2198
+ const { seq, method, args } = msg;
2199
+ const reply = (body) => {
2200
+ try {
2201
+ worker.postMessage({ type: "tasks-result", seq, ...body });
2202
+ } catch (err) {
2203
+ try {
2204
+ worker.postMessage({
2205
+ type: "tasks-result",
2206
+ seq,
2207
+ ok: false,
2208
+ error: {
2209
+ name: "Error",
2210
+ message: `taskCtx.tasks.${method} result is not cloneable: ${String(err)}`,
2211
+ stack: void 0,
2212
+ props: {}
2213
+ }
2214
+ });
2215
+ } catch {
2216
+ }
2217
+ }
2218
+ };
2219
+ if (!options.onTasksCall) {
2220
+ reply({
2221
+ ok: false,
2222
+ error: {
2223
+ name: "Error",
2224
+ message: `taskCtx.tasks.${method} is unavailable: no host handler was provided for this execution`,
2225
+ stack: void 0,
2226
+ props: {}
2227
+ }
2228
+ });
2229
+ return;
2230
+ }
2231
+ options.onTasksCall(method, args ?? []).then((value) => reply({ ok: true, value })).catch((err) => {
2232
+ reply({
2233
+ ok: false,
2234
+ error: {
2235
+ name: err instanceof Error ? err.name : "Error",
2236
+ message: err instanceof Error ? err.message : String(err),
2237
+ stack: err instanceof Error ? err.stack : void 0,
2238
+ props: {}
2239
+ }
2240
+ });
2241
+ });
2242
+ return;
2243
+ }
2210
2244
  if (phase === "grace") {
2211
2245
  if (msg?.type === "done") {
2212
2246
  finish(
@@ -2253,7 +2287,6 @@ async function runTaskInWorker(options) {
2253
2287
  type: "run",
2254
2288
  payload,
2255
2289
  taskCtx: {
2256
- config: safeConfig(taskCtx.config),
2257
2290
  job: taskCtx.job
2258
2291
  },
2259
2292
  registries: options.registries,
@@ -2262,14 +2295,122 @@ async function runTaskInWorker(options) {
2262
2295
  });
2263
2296
  } catch (err) {
2264
2297
  finish(
2265
- () => reject(
2266
- new Error(`Task "${taskCtx.job.name}" payload/config is not cloneable: ${String(err)}`)
2267
- )
2298
+ () => reject(new Error(`Task "${taskCtx.job.name}" payload is not cloneable: ${String(err)}`))
2268
2299
  );
2269
2300
  }
2270
2301
  });
2271
2302
  }
2272
2303
 
2304
+ // src/utils/isPlainObject.ts
2305
+ function isPlainObject(value) {
2306
+ if (value === null || typeof value !== "object") {
2307
+ return false;
2308
+ }
2309
+ if (Array.isArray(value)) {
2310
+ return false;
2311
+ }
2312
+ const proto = Object.getPrototypeOf(value);
2313
+ return proto === null || proto === Object.prototype;
2314
+ }
2315
+
2316
+ // src/utils/stringifyJson.ts
2317
+ function stringifyJson(value) {
2318
+ if (!needsConversion(value, /* @__PURE__ */ new Set())) {
2319
+ return JSON.stringify(value);
2320
+ }
2321
+ return JSON.stringify(convertValue(value, /* @__PURE__ */ new Set()));
2322
+ }
2323
+ function needsConversion(value, ancestors) {
2324
+ if (value === null) return false;
2325
+ const type = typeof value;
2326
+ if (type === "bigint") return true;
2327
+ if (type === "number") return !Number.isFinite(value);
2328
+ if (type !== "object") return false;
2329
+ const obj = value;
2330
+ if (ancestors.has(obj)) {
2331
+ throw new TypeError("[faapi] Converting circular structure to JSON");
2332
+ }
2333
+ if (obj instanceof Date || obj instanceof Map || obj instanceof Set || obj instanceof RegExp) {
2334
+ return true;
2335
+ }
2336
+ if (typeof obj.toJSON === "function") return true;
2337
+ if (Array.isArray(obj)) {
2338
+ ancestors.add(obj);
2339
+ for (let i = 0; i < obj.length; i++) {
2340
+ if (needsConversion(obj[i], ancestors)) {
2341
+ ancestors.delete(obj);
2342
+ return true;
2343
+ }
2344
+ }
2345
+ ancestors.delete(obj);
2346
+ return false;
2347
+ }
2348
+ if (isPlainObject(obj)) {
2349
+ ancestors.add(obj);
2350
+ for (const key in obj) {
2351
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
2352
+ if (needsConversion(obj[key], ancestors)) {
2353
+ ancestors.delete(obj);
2354
+ return true;
2355
+ }
2356
+ }
2357
+ ancestors.delete(obj);
2358
+ return false;
2359
+ }
2360
+ return false;
2361
+ }
2362
+ function convertValue(value, ancestors) {
2363
+ if (value === null) return value;
2364
+ const type = typeof value;
2365
+ if (type === "bigint") return value.toString();
2366
+ if (type === "number") return Number.isFinite(value) ? value : String(value);
2367
+ if (type !== "object") return value;
2368
+ const obj = value;
2369
+ if (ancestors.has(obj)) {
2370
+ throw new TypeError("[faapi] Converting circular structure to JSON");
2371
+ }
2372
+ if (obj instanceof Date) return obj.getTime();
2373
+ if (obj instanceof Map) {
2374
+ ancestors.add(obj);
2375
+ const entries = Array.from(obj, ([k, v]) => [
2376
+ convertValue(k, ancestors),
2377
+ convertValue(v, ancestors)
2378
+ ]);
2379
+ ancestors.delete(obj);
2380
+ return entries;
2381
+ }
2382
+ if (obj instanceof Set) {
2383
+ ancestors.add(obj);
2384
+ const values = Array.from(obj, (v) => convertValue(v, ancestors));
2385
+ ancestors.delete(obj);
2386
+ return values;
2387
+ }
2388
+ if (obj instanceof RegExp) return obj.toString();
2389
+ const toJSON = obj.toJSON;
2390
+ if (typeof toJSON === "function") {
2391
+ ancestors.add(obj);
2392
+ const result = convertValue(toJSON.call(obj), ancestors);
2393
+ ancestors.delete(obj);
2394
+ return result;
2395
+ }
2396
+ if (Array.isArray(obj)) {
2397
+ ancestors.add(obj);
2398
+ const arr = obj.map((item) => convertValue(item, ancestors));
2399
+ ancestors.delete(obj);
2400
+ return arr;
2401
+ }
2402
+ if (isPlainObject(obj)) {
2403
+ ancestors.add(obj);
2404
+ const out = {};
2405
+ for (const [key, val] of Object.entries(obj)) {
2406
+ out[key] = convertValue(val, ancestors);
2407
+ }
2408
+ ancestors.delete(obj);
2409
+ return out;
2410
+ }
2411
+ return obj;
2412
+ }
2413
+
2273
2414
  // src/logger/formatEntry.ts
2274
2415
  function serializeFieldValue(value) {
2275
2416
  if (value instanceof Error) {
@@ -2285,11 +2426,7 @@ function formatEntry(entry) {
2285
2426
  const plain = Object.fromEntries(
2286
2427
  Object.entries(entry.fields).map(([k, v]) => [k, serializeFieldValue(v)])
2287
2428
  );
2288
- try {
2289
- line += ` ${JSON.stringify(plain)}`;
2290
- } catch (err) {
2291
- line += ` [unserializable fields: ${err instanceof Error ? err.message : String(err)}]`;
2292
- }
2429
+ line += ` ${stringifyJson(plain)}`;
2293
2430
  }
2294
2431
  return line;
2295
2432
  }
@@ -2307,7 +2444,14 @@ function createFileLogSink(options) {
2307
2444
  };
2308
2445
  const openStream = (name) => {
2309
2446
  const stream = fs2.createWriteStream(path3.join(options.dir, `${name}.log`), { flags: "a" });
2310
- stream.on("error", () => {
2447
+ let streamErrorLogged = false;
2448
+ stream.on("error", (err) => {
2449
+ if (streamErrorLogged) return;
2450
+ streamErrorLogged = true;
2451
+ console.error(
2452
+ `[faapi] log file stream "${name}.log" failed \u2014 file logging on this stream stopped (subsequent errors suppressed). Check disk space / permissions for ${options.dir}:`,
2453
+ err
2454
+ );
2311
2455
  });
2312
2456
  streams.set(name, stream);
2313
2457
  };
@@ -2508,6 +2652,17 @@ function createLogger(scope, options) {
2508
2652
 
2509
2653
  // src/task/taskQueue.ts
2510
2654
  var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["done", "failed", "cancelled"]);
2655
+ var GROUP_MEMBER_DEDUP = (groupId, index) => `faapi-group:${groupId}:${index}`;
2656
+ var GROUP_COMPLETION_DEDUP = (groupId) => `faapi-group:${groupId}:complete`;
2657
+ var TASK_CLIENT_METHODS = /* @__PURE__ */ new Set([
2658
+ "enqueue",
2659
+ "enqueueGroup",
2660
+ "list",
2661
+ "listQueued",
2662
+ "cancel",
2663
+ "retry",
2664
+ "getGroup"
2665
+ ]);
2511
2666
  var MAX_FINISHED_RECORDS = 1e3;
2512
2667
  function createTaskQueue(deps) {
2513
2668
  const { registry, rootDir, driver } = deps;
@@ -2539,13 +2694,14 @@ function createTaskQueue(deps) {
2539
2694
  const loadTaskModule = deps.loadTaskModule ?? (async (filePath) => await import(filePath));
2540
2695
  const loadPayloadSchema = deps.loadPayloadSchema ?? (async (filePath) => {
2541
2696
  const zodPath = path4.join(path4.dirname(filePath), "zod.js");
2542
- try {
2543
- const mod = await import(zodPath);
2544
- const schemaKey = Object.keys(mod).find((k) => k.endsWith("Schema"));
2545
- return schemaKey ? mod[schemaKey] : void 0;
2546
- } catch {
2547
- return void 0;
2697
+ const mod = await import(zodPath);
2698
+ const schemaKey = Object.keys(mod).find((k) => k.endsWith("Schema"));
2699
+ if (!schemaKey) {
2700
+ throw new Error(
2701
+ `[faapi] Task payload schema artifact has no *Schema export: ${zodPath} \u2014 re-run "faapi dev" / "faapi build" to regenerate task artifacts.`
2702
+ );
2548
2703
  }
2704
+ return mod[schemaKey];
2549
2705
  });
2550
2706
  function assertKnownTask(name) {
2551
2707
  const meta = registry.get(name);
@@ -2554,6 +2710,54 @@ function createTaskQueue(deps) {
2554
2710
  throw new Error(`[faapi] Unknown task "${name}". Registered tasks: ${known}`);
2555
2711
  }
2556
2712
  }
2713
+ function assertGroupSupport() {
2714
+ if (!driver.groups) {
2715
+ throw new Error(
2716
+ "[faapi] Task driver does not support group accounting (TaskDriver.groups is not implemented). Group enqueue/settle requires a driver that implements it \u2014 see driverTypes.md / taskGroups.md."
2717
+ );
2718
+ }
2719
+ }
2720
+ async function maybeFireCompletion(snap) {
2721
+ if (!driver.groups) return;
2722
+ if (!snap.onComplete || snap.completionEnqueued || snap.settled < snap.total) return;
2723
+ const summary = {
2724
+ groupId: snap.groupId,
2725
+ task: snap.task,
2726
+ total: snap.total,
2727
+ done: snap.done,
2728
+ failed: snap.failed,
2729
+ cancelled: snap.cancelled,
2730
+ settled: snap.settled
2731
+ };
2732
+ try {
2733
+ await enqueueCore(snap.onComplete, summary, {
2734
+ dedupId: GROUP_COMPLETION_DEDUP(snap.groupId)
2735
+ });
2736
+ await driver.groups.markCompletionEnqueued(snap.groupId);
2737
+ } catch (err) {
2738
+ console.error(
2739
+ `[faapi] task group "${snap.groupId}" completion enqueue failed (group is settled; re-invoke enqueueGroup with the same groupId to re-arm the callback):`,
2740
+ err
2741
+ );
2742
+ }
2743
+ }
2744
+ async function settleGroup(record, outcome, willRetry) {
2745
+ const groupId = record.groupId;
2746
+ if (!groupId || !driver.groups) return;
2747
+ if (outcome !== "done" && willRetry) return;
2748
+ try {
2749
+ const snap = await driver.groups.settle(groupId, record.id, outcome);
2750
+ if (outcome === "failed" && snap.onFailure === "fail-fast") {
2751
+ await maybeFireCompletion(await driver.groups.cancelRemaining(groupId));
2752
+ }
2753
+ await maybeFireCompletion(snap);
2754
+ } catch (err) {
2755
+ console.error(
2756
+ `[faapi] task group accounting failed for "${groupId}" (job ${record.id}, outcome ${outcome}):`,
2757
+ err
2758
+ );
2759
+ }
2760
+ }
2557
2761
  function snapshotRegistries() {
2558
2762
  return {
2559
2763
  agents: registriesView.agent.listAgents().map((core) => registriesView.agent.getAgentEntry(core.name)).filter((entry) => entry !== void 0),
@@ -2564,14 +2768,13 @@ function createTaskQueue(deps) {
2564
2768
  async function validatePayload(name, payload) {
2565
2769
  if (!schemaCache.has(name)) {
2566
2770
  const meta = registry.get(name);
2567
- schemaCache.set(
2568
- name,
2569
- await loadPayloadSchema(path4.resolve(rootDir, meta.filePath)).catch(() => void 0)
2570
- );
2771
+ schemaCache.set(name, await loadPayloadSchema(path4.resolve(rootDir, meta.filePath)));
2571
2772
  }
2572
2773
  const schema = schemaCache.get(name);
2573
2774
  if (!schema || typeof schema.safeParse !== "function") {
2574
- return payload;
2775
+ throw new Error(
2776
+ `[faapi] Task "${name}" has no valid payload schema (zod.js missing or no *Schema export). Payload declarations are mandatory: re-run "faapi dev" / "faapi build" to regenerate task artifacts.`
2777
+ );
2575
2778
  }
2576
2779
  const result = schema.safeParse(payload);
2577
2780
  if (!result.success) {
@@ -2593,11 +2796,13 @@ function createTaskQueue(deps) {
2593
2796
  attempts: 0,
2594
2797
  createdAt: Date.now()
2595
2798
  };
2799
+ if (job.groupId !== void 0) record.groupId = job.groupId;
2596
2800
  record.attempts = job.attempt;
2597
2801
  transitionStatus(record, "running");
2598
2802
  delete record.progress;
2599
2803
  record.error = void 0;
2600
2804
  records.set(job.id, record);
2805
+ const willRetry = job.attempt <= (meta.retries ?? 0);
2601
2806
  try {
2602
2807
  let result;
2603
2808
  if (meta.timeoutMs !== void 0 && meta.timeoutMs > 0) {
@@ -2605,7 +2810,8 @@ function createTaskQueue(deps) {
2605
2810
  taskModulePath: path4.resolve(rootDir, meta.filePath),
2606
2811
  payload: job.payload,
2607
2812
  taskCtx: {
2608
- config: deps.config,
2813
+ // 隔离任务不传 config:worker 线程不接收进程配置(config 含函数字段
2814
+ // 不可结构化克隆,框架不做降级传递)——任务数据经 payload 显式传入
2609
2815
  resourcesDir: deps.resourcesDir,
2610
2816
  job: { id: job.id, name: job.name, attempt: job.attempt }
2611
2817
  },
@@ -2626,7 +2832,16 @@ function createTaskQueue(deps) {
2626
2832
  onProgress: (value) => {
2627
2833
  if (record.status === "running") record.progress = value;
2628
2834
  },
2629
- onLog: writeLogEntry
2835
+ onLog: writeLogEntry,
2836
+ // 隔离路径的 taskCtx.tasks 为 postMessage RPC 代理——宿主侧按方法名
2837
+ // 分派到队列本体(与 ctx.tasks / app.tasks 同一实例),结果/错误按 seq 回传
2838
+ onTasksCall: async (method, args) => {
2839
+ if (!TASK_CLIENT_METHODS.has(method)) {
2840
+ throw new Error(`[faapi] Unknown task client method "${method}"`);
2841
+ }
2842
+ const fn = queue[method];
2843
+ return fn.apply(queue, args);
2844
+ }
2630
2845
  });
2631
2846
  } else {
2632
2847
  let mod = moduleCache2.get(job.name);
@@ -2639,6 +2854,8 @@ function createTaskQueue(deps) {
2639
2854
  }
2640
2855
  const taskCtx = {
2641
2856
  signal: job.signal,
2857
+ // 活引用:与 ctx.tasks / app.tasks 同一 app 实例队列
2858
+ tasks: queue,
2642
2859
  config: deps.config,
2643
2860
  job: { id: job.id, name: job.name, attempt: job.attempt },
2644
2861
  registries: registriesView,
@@ -2656,23 +2873,27 @@ function createTaskQueue(deps) {
2656
2873
  transitionStatus(record, "done");
2657
2874
  record.result = result;
2658
2875
  evictFinishedRecords();
2876
+ await settleGroup(record, "done", willRetry);
2659
2877
  return result;
2660
2878
  } catch (err) {
2661
2879
  const cancelled = err instanceof TaskCancelledError || job.signal.aborted;
2662
- transitionStatus(record, cancelled ? "cancelled" : "failed");
2880
+ const outcome = cancelled ? "cancelled" : "failed";
2881
+ transitionStatus(record, outcome);
2663
2882
  record.error = err instanceof Error ? err.message : String(err);
2664
2883
  evictFinishedRecords();
2884
+ await settleGroup(record, outcome, willRetry);
2665
2885
  if (deps.onFailed) {
2666
2886
  void Promise.resolve().then(
2667
2887
  () => deps.onFailed({
2668
2888
  task: job.name,
2669
2889
  jobId: job.id,
2670
2890
  attempt: job.attempt,
2671
- willRetry: job.attempt <= (meta.retries ?? 0),
2891
+ willRetry,
2672
2892
  cancelled,
2673
2893
  error: record.error
2674
2894
  })
2675
- ).catch(() => {
2895
+ ).catch((hookErr) => {
2896
+ console.error(`[faapi] task onFailed hook threw for "${job.name}":`, hookErr);
2676
2897
  });
2677
2898
  }
2678
2899
  throw err;
@@ -2686,35 +2907,86 @@ function createTaskQueue(deps) {
2686
2907
  });
2687
2908
  }
2688
2909
  }
2910
+ async function enqueueCore(name, payload, opts) {
2911
+ if (stopped) {
2912
+ throw new Error("[faapi] Task queue is stopped and no longer accepts jobs");
2913
+ }
2914
+ assertKnownTask(name);
2915
+ const data = await validatePayload(name, payload);
2916
+ const meta = registry.get(name);
2917
+ const id = await driver.enqueue(name, data, {
2918
+ delayMs: opts?.delayMs,
2919
+ retries: meta.retries ?? 0,
2920
+ dedupId: opts?.dedupId,
2921
+ // 执行硬限预算透传驱动(pgboss 映射 expireInSeconds):未声明 timeoutMs 时
2922
+ // 两字段为 undefined,驱动用自身兜底
2923
+ timeoutMs: meta.timeoutMs,
2924
+ graceMs: meta.graceMs,
2925
+ ...opts?.groupId !== void 0 ? { groupId: opts.groupId } : {}
2926
+ });
2927
+ if (!records.has(id)) {
2928
+ records.set(id, {
2929
+ id,
2930
+ name,
2931
+ payload: data,
2932
+ status: "pending",
2933
+ attempts: 0,
2934
+ ...opts?.groupId !== void 0 ? { groupId: opts.groupId } : {},
2935
+ createdAt: Date.now(),
2936
+ ...opts?.delayMs ? { runAt: Date.now() + opts.delayMs } : {}
2937
+ });
2938
+ }
2939
+ return id;
2940
+ }
2689
2941
  const queue = {
2690
2942
  async enqueue(name, payload = {}, opts) {
2943
+ const id = await enqueueCore(name, payload, {
2944
+ delayMs: opts?.delayMs,
2945
+ dedupId: opts?.dedupId
2946
+ });
2947
+ return { id };
2948
+ },
2949
+ async enqueueGroup(name, payloads, opts) {
2691
2950
  if (stopped) {
2692
2951
  throw new Error("[faapi] Task queue is stopped and no longer accepts jobs");
2693
2952
  }
2953
+ assertGroupSupport();
2694
2954
  assertKnownTask(name);
2695
- const data = await validatePayload(name, payload);
2696
- const meta = registry.get(name);
2697
- const id = await driver.enqueue(name, data, {
2698
- delayMs: opts?.delayMs,
2699
- retries: meta.retries ?? 0,
2700
- dedupId: opts?.dedupId,
2701
- // 执行硬限预算透传驱动(pgboss 映射 expireInSeconds):未声明 timeoutMs 时
2702
- // 两字段为 undefined,驱动用自身兜底
2703
- timeoutMs: meta.timeoutMs,
2704
- graceMs: meta.graceMs
2955
+ if (!Array.isArray(payloads) || payloads.length === 0) {
2956
+ throw new Error(
2957
+ "[faapi] enqueueGroup requires a non-empty payloads array \u2014 a group without members can never settle."
2958
+ );
2959
+ }
2960
+ const onComplete = opts?.onComplete;
2961
+ if (onComplete !== void 0) assertKnownTask(onComplete);
2962
+ const onFailure = opts?.onFailure ?? "run-to-completion";
2963
+ const validated = [];
2964
+ for (const p of payloads) {
2965
+ validated.push(await validatePayload(name, p));
2966
+ }
2967
+ const groupId = opts?.groupId ?? crypto.randomUUID();
2968
+ await driver.groups.create({
2969
+ id: groupId,
2970
+ task: name,
2971
+ total: validated.length,
2972
+ ...onComplete !== void 0 ? { onComplete } : {},
2973
+ onFailure
2705
2974
  });
2706
- if (!records.has(id)) {
2707
- records.set(id, {
2708
- id,
2709
- name,
2710
- payload: data,
2711
- status: "pending",
2712
- attempts: 0,
2713
- createdAt: Date.now(),
2714
- ...opts?.delayMs ? { runAt: Date.now() + opts.delayMs } : {}
2975
+ const jobs = [];
2976
+ for (let i = 0; i < validated.length; i++) {
2977
+ const id = await enqueueCore(name, validated[i], {
2978
+ delayMs: opts?.delayMs,
2979
+ dedupId: GROUP_MEMBER_DEDUP(groupId, i + 1),
2980
+ groupId
2715
2981
  });
2982
+ jobs.push({ id });
2716
2983
  }
2717
- return { id };
2984
+ await maybeFireCompletion(await driver.groups.get(groupId));
2985
+ return { groupId, jobs };
2986
+ },
2987
+ async getGroup(groupId) {
2988
+ assertGroupSupport();
2989
+ return driver.groups.get(groupId);
2718
2990
  },
2719
2991
  list(name) {
2720
2992
  const snapshot = [];
@@ -2741,6 +3013,7 @@ function createTaskQueue(deps) {
2741
3013
  status: record.status,
2742
3014
  attempts: record.attempts,
2743
3015
  createdAt: record.createdAt,
3016
+ ...record.groupId !== void 0 ? { groupId: record.groupId } : {},
2744
3017
  ...record.result !== void 0 ? { result: record.result } : {},
2745
3018
  ...record.error !== void 0 ? { error: record.error } : {},
2746
3019
  ...record.runAt !== void 0 ? { runAt: record.runAt } : {}
@@ -2760,8 +3033,11 @@ function createTaskQueue(deps) {
2760
3033
  }
2761
3034
  await driver.cancel(name, id);
2762
3035
  const record = records.get(id);
2763
- if (record) transitionStatus(record, "cancelled");
2764
- evictFinishedRecords();
3036
+ if (record) {
3037
+ transitionStatus(record, "cancelled");
3038
+ evictFinishedRecords();
3039
+ await settleGroup(record, "cancelled", false);
3040
+ }
2765
3041
  },
2766
3042
  async retry(name, id) {
2767
3043
  if (!driver.retry) {
@@ -2771,7 +3047,18 @@ function createTaskQueue(deps) {
2771
3047
  }
2772
3048
  await driver.retry(name, id);
2773
3049
  const record = records.get(id);
2774
- if (record) transitionStatus(record, "pending");
3050
+ if (record) {
3051
+ const wasTerminal = TERMINAL_STATUSES.has(record.status);
3052
+ transitionStatus(record, "pending");
3053
+ if (wasTerminal && record.groupId && driver.groups) {
3054
+ await driver.groups.unsettle(record.groupId, record.id).catch((err) => {
3055
+ console.error(
3056
+ `[faapi] task group unsettle failed for "${record.groupId}" (job ${record.id}):`,
3057
+ err
3058
+ );
3059
+ });
3060
+ }
3061
+ }
2775
3062
  },
2776
3063
  async start() {
2777
3064
  if (stopped) {
@@ -2895,25 +3182,27 @@ function detectIgnoredMetaFields(source, strictHit) {
2895
3182
  for (const line of source.split("\n")) {
2896
3183
  const trimmed = line.trim();
2897
3184
  if (trimmed.startsWith("//")) continue;
3185
+ if (trimmed.startsWith("*") || trimmed.startsWith("/*") || trimmed.endsWith("*/")) continue;
2898
3186
  for (const field of ALL_META_FIELDS) {
2899
3187
  if (new RegExp(`\\b${field}\\s*:`).test(trimmed)) declared.add(field);
2900
3188
  }
2901
3189
  }
2902
3190
  return ALL_META_FIELDS.filter((field) => declared.has(field) && !strictHit[field]);
2903
3191
  }
2904
- function warnIgnoredMetaFields(name, fields) {
2905
- for (const field of fields) {
3192
+ function throwIgnoredMetaFields(name, fields) {
3193
+ if (fields.length === 0) return;
3194
+ const details = fields.map((field) => {
2906
3195
  if (STRING_META_FIELDS.includes(field)) {
2907
- console.warn(
2908
- `[faapi] Task "${name}" declares "${field}" but its value is not a string literal \u2014 the declaration was ignored and the task will not be cron-scheduled. Meta values are extracted from source code without importing it, so only literal values are recognized: use a quoted literal (cron: '0 3 * * *'), not an expression or a dynamic value.`
2909
- );
2910
- } else {
2911
- const consequence = field === "timeoutMs" ? " and the task will run in-process with no timeout" : "";
2912
- console.warn(
2913
- `[faapi] Task "${name}" declares "${field}" but its value is not a numeric literal \u2014 the declaration was ignored${consequence}. Meta values are extracted from source code without importing it, so only literal values are recognized (underscores allowed, e.g. 1_800_000); expressions like 30 * 60_000 or dynamic values are not supported.`
2914
- );
3196
+ return `"${field}": value is not a string literal \u2014 the declaration would be ignored and the task would not be cron-scheduled. Use a quoted literal (cron: '0 3 * * *'), not an expression or a dynamic value.`;
2915
3197
  }
2916
- }
3198
+ const consequence = field === "timeoutMs" ? " \u2014 the declaration would be ignored and the task would silently run in-process with no timeout" : " \u2014 the declaration would be ignored";
3199
+ return `"${field}": value is not a numeric literal${consequence}. Only literal values are recognized (underscores allowed, e.g. 1_800_000); expressions like 30 * 60_000 or dynamic values are not supported.`;
3200
+ });
3201
+ throw new Error(
3202
+ `[faapi] Task "${name}" declares meta field(s) with non-literal values:
3203
+ ${details.join("\n ")}
3204
+ Meta values are extracted from source code without importing it, so only literals are recognized. If a matched line is inside a block comment (e.g. a single-line /* ... */ comment containing a meta-like declaration), rewrite the comment so it does not look like a meta declaration.`
3205
+ );
2917
3206
  }
2918
3207
  function parseNumericLiteral(raw) {
2919
3208
  return Number(raw.replace(/_/g, ""));
@@ -2983,7 +3272,7 @@ async function scanTasks(rootDir, patterns) {
2983
3272
  timeoutMs: timeoutMs !== void 0,
2984
3273
  graceMs: graceMs !== void 0
2985
3274
  };
2986
- warnIgnoredMetaFields(name, detectIgnoredMetaFields(source, strictHit));
3275
+ throwIgnoredMetaFields(name, detectIgnoredMetaFields(source, strictHit));
2987
3276
  tasks.push(manifest);
2988
3277
  }
2989
3278
  return tasks.sort((a, b) => a.name.localeCompare(b.name));
@@ -3583,11 +3872,7 @@ async function ensureMiddlewaresCompiled(middlewarePaths, rootDir) {
3583
3872
  if (!dist) return;
3584
3873
  for (const mwPath of middlewarePaths) {
3585
3874
  const sourcePath = prodPathToSourcePath(mwPath, rootDir, dist);
3586
- try {
3587
- await ensureCompiled(sourcePath, rootDir, dist);
3588
- } catch (err) {
3589
- console.error(`[faapi] Failed to compile middleware source ${sourcePath}:`, err);
3590
- }
3875
+ await ensureCompiled(sourcePath, rootDir, dist);
3591
3876
  }
3592
3877
  }
3593
3878
  function prodSourcePathToProductPath(sourceAbsPath, rootDir, dist) {
@@ -4591,116 +4876,6 @@ function hasBufferedBody(response) {
4591
4876
  return bufferedBodyResponses.has(response);
4592
4877
  }
4593
4878
 
4594
- // src/utils/isPlainObject.ts
4595
- function isPlainObject(value) {
4596
- if (value === null || typeof value !== "object") {
4597
- return false;
4598
- }
4599
- if (Array.isArray(value)) {
4600
- return false;
4601
- }
4602
- const proto = Object.getPrototypeOf(value);
4603
- return proto === null || proto === Object.prototype;
4604
- }
4605
-
4606
- // src/utils/stringifyJson.ts
4607
- function stringifyJson(value) {
4608
- if (!needsConversion(value, /* @__PURE__ */ new Set())) {
4609
- return JSON.stringify(value);
4610
- }
4611
- return JSON.stringify(convertValue(value, /* @__PURE__ */ new Set()));
4612
- }
4613
- function needsConversion(value, ancestors) {
4614
- if (value === null) return false;
4615
- const type = typeof value;
4616
- if (type === "bigint") return true;
4617
- if (type === "number") return !Number.isFinite(value);
4618
- if (type !== "object") return false;
4619
- const obj = value;
4620
- if (ancestors.has(obj)) {
4621
- throw new TypeError("[faapi] Converting circular structure to JSON");
4622
- }
4623
- if (obj instanceof Date || obj instanceof Map || obj instanceof Set || obj instanceof RegExp) {
4624
- return true;
4625
- }
4626
- if (typeof obj.toJSON === "function") return true;
4627
- if (Array.isArray(obj)) {
4628
- ancestors.add(obj);
4629
- for (let i = 0; i < obj.length; i++) {
4630
- if (needsConversion(obj[i], ancestors)) {
4631
- ancestors.delete(obj);
4632
- return true;
4633
- }
4634
- }
4635
- ancestors.delete(obj);
4636
- return false;
4637
- }
4638
- if (isPlainObject(obj)) {
4639
- ancestors.add(obj);
4640
- for (const key in obj) {
4641
- if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
4642
- if (needsConversion(obj[key], ancestors)) {
4643
- ancestors.delete(obj);
4644
- return true;
4645
- }
4646
- }
4647
- ancestors.delete(obj);
4648
- return false;
4649
- }
4650
- return false;
4651
- }
4652
- function convertValue(value, ancestors) {
4653
- if (value === null) return value;
4654
- const type = typeof value;
4655
- if (type === "bigint") return value.toString();
4656
- if (type === "number") return Number.isFinite(value) ? value : String(value);
4657
- if (type !== "object") return value;
4658
- const obj = value;
4659
- if (ancestors.has(obj)) {
4660
- throw new TypeError("[faapi] Converting circular structure to JSON");
4661
- }
4662
- if (obj instanceof Date) return obj.getTime();
4663
- if (obj instanceof Map) {
4664
- ancestors.add(obj);
4665
- const entries = Array.from(obj, ([k, v]) => [
4666
- convertValue(k, ancestors),
4667
- convertValue(v, ancestors)
4668
- ]);
4669
- ancestors.delete(obj);
4670
- return entries;
4671
- }
4672
- if (obj instanceof Set) {
4673
- ancestors.add(obj);
4674
- const values = Array.from(obj, (v) => convertValue(v, ancestors));
4675
- ancestors.delete(obj);
4676
- return values;
4677
- }
4678
- if (obj instanceof RegExp) return obj.toString();
4679
- const toJSON = obj.toJSON;
4680
- if (typeof toJSON === "function") {
4681
- ancestors.add(obj);
4682
- const result = convertValue(toJSON.call(obj), ancestors);
4683
- ancestors.delete(obj);
4684
- return result;
4685
- }
4686
- if (Array.isArray(obj)) {
4687
- ancestors.add(obj);
4688
- const arr = obj.map((item) => convertValue(item, ancestors));
4689
- ancestors.delete(obj);
4690
- return arr;
4691
- }
4692
- if (isPlainObject(obj)) {
4693
- ancestors.add(obj);
4694
- const out = {};
4695
- for (const [key, val] of Object.entries(obj)) {
4696
- out[key] = convertValue(val, ancestors);
4697
- }
4698
- ancestors.delete(obj);
4699
- return out;
4700
- }
4701
- return obj;
4702
- }
4703
-
4704
4879
  // src/runtime/sse.ts
4705
4880
  function encodeSseEvent(event) {
4706
4881
  let out = "";
@@ -5898,38 +6073,30 @@ function setCachedMiddlewares(absPath, bundle) {
5898
6073
  middlewareCache.set(absPath, bundle);
5899
6074
  }
5900
6075
  async function loadMiddlewaresFile(filePath) {
5901
- let module;
5902
- try {
5903
- module = await importWithCacheBust(filePath);
5904
- } catch (err) {
5905
- console.error(
5906
- `[faapi] Failed to load middlewares from ${filePath}:`,
5907
- err instanceof Error ? err.stack ?? err.message : err
5908
- );
5909
- return { middlewares: [], injectors: {} };
5910
- }
6076
+ const module = await importWithCacheBust(filePath);
5911
6077
  const middlewares = module.default ?? module.middlewares ?? [];
5912
6078
  if (!Array.isArray(middlewares)) {
5913
- console.warn(`[faapi] middlewares.ts \u5E94\u5BFC\u51FA\u6570\u7EC4\uFF0C\u5DF2\u5FFD\u7565: ${filePath}`);
5914
- return { middlewares: [], injectors: {} };
6079
+ throw new TypeError(
6080
+ `[faapi] middlewares.ts must export an array (default or named "middlewares"): ${filePath}`
6081
+ );
5915
6082
  }
5916
- const validMiddlewares = middlewares.filter((m) => {
6083
+ const validMiddlewares = [];
6084
+ for (const m of middlewares) {
5917
6085
  if (typeof m !== "function") {
5918
- console.warn(`[faapi] \u65E0\u6548\u7684\u4E2D\u95F4\u4EF6\u9879\uFF08\u5E94\u4E3A\u51FD\u6570\uFF09\uFF0C\u5DF2\u5FFD\u7565: ${typeof m}`);
5919
- return false;
6086
+ throw new TypeError(
6087
+ `[faapi] every middleware must be a function, got ${typeof m}: ${filePath}`
6088
+ );
5920
6089
  }
5921
- return true;
5922
- });
6090
+ validMiddlewares.push(m);
6091
+ }
5923
6092
  const injectors = module.injectors ?? {};
5924
6093
  if (typeof injectors !== "object" || injectors === null) {
5925
- console.warn(`[faapi] injectors \u5E94\u5BFC\u51FA\u5BF9\u8C61\uFF0C\u5DF2\u5FFD\u7565: ${filePath}`);
5926
- return { middlewares: validMiddlewares, injectors: {} };
6094
+ throw new TypeError(`[faapi] "injectors" export must be an object: ${filePath}`);
5927
6095
  }
5928
6096
  const validInjectors = {};
5929
6097
  for (const [name, injector] of Object.entries(injectors)) {
5930
6098
  if (typeof injector !== "function") {
5931
- console.warn(`[faapi] \u6CE8\u5165\u5668 ${name} \u5E94\u4E3A\u51FD\u6570\uFF0C\u5DF2\u5FFD\u7565`);
5932
- continue;
6099
+ throw new TypeError(`[faapi] injector "${name}" must be a function: ${filePath}`);
5933
6100
  }
5934
6101
  validInjectors[name] = injector;
5935
6102
  }
@@ -5944,10 +6111,16 @@ async function loadMergedMiddlewares(middlewarePaths) {
5944
6111
  if (bundle === void 0) {
5945
6112
  let loading = inFlight.get(absMwPath);
5946
6113
  if (!loading) {
5947
- loading = loadMiddlewaresFile(absMwPath).then((result) => {
5948
- setCachedMiddlewares(absMwPath, result);
5949
- return result;
5950
- });
6114
+ loading = loadMiddlewaresFile(absMwPath).then(
6115
+ (result) => {
6116
+ setCachedMiddlewares(absMwPath, result);
6117
+ return result;
6118
+ },
6119
+ (err) => {
6120
+ inFlight.delete(absMwPath);
6121
+ throw err;
6122
+ }
6123
+ );
5951
6124
  inFlight.set(absMwPath, loading);
5952
6125
  }
5953
6126
  bundle = await loading;
@@ -6542,7 +6715,8 @@ async function sendErrorResponse(err, meta, res, onError, ctx) {
6542
6715
  if (onError && ctx) {
6543
6716
  try {
6544
6717
  await onError(err, ctx);
6545
- } catch {
6718
+ } catch (hookErr) {
6719
+ console.error("[faapi] lifecycle onError hook threw:", hookErr);
6546
6720
  }
6547
6721
  }
6548
6722
  }
@@ -6735,7 +6909,7 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
6735
6909
  const upgradeWrappers = [];
6736
6910
  const failures = [];
6737
6911
  if (!declarations || declarations.length === 0) {
6738
- return { handlerWrappers, upgradeWrappers, failures };
6912
+ return { handlerWrappers, upgradeWrappers };
6739
6913
  }
6740
6914
  const fullCtx = {
6741
6915
  ...ctx,
@@ -6762,7 +6936,7 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
6762
6936
  }
6763
6937
  if (enable === false) continue;
6764
6938
  if (loaded.has(specifier)) {
6765
- console.warn(`! Plugin already loaded: ${specifier}, skipping`);
6939
+ failures.push({ specifier, reason: "duplicate plugin declaration" });
6766
6940
  continue;
6767
6941
  }
6768
6942
  loaded.add(specifier);
@@ -6783,12 +6957,12 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
6783
6957
  }
6784
6958
  }
6785
6959
  if (failures.length > 0) {
6786
- console.error(
6960
+ throw new Error(
6787
6961
  `[faapi] ${failures.length} plugin(s) failed to load:
6788
6962
  ` + failures.map((f) => ` - ${f.specifier}: ${f.reason}`).join("\n")
6789
6963
  );
6790
6964
  }
6791
- return { handlerWrappers, upgradeWrappers, failures };
6965
+ return { handlerWrappers, upgradeWrappers };
6792
6966
  }
6793
6967
  var TS_EXTS = /\.(ts|tsx|mts|cts)$/;
6794
6968
  var JS_EXTS = /\.(js|mjs|cjs|jsx)$/;
@@ -7389,6 +7563,7 @@ function generateTaskSchemaFileSource(sources, resolveType, helpersImportPath) {
7389
7563
  }
7390
7564
  async function generateTaskArtifacts(manifests, rootDir, dist) {
7391
7565
  const metadata = [];
7566
+ const declarationErrors = [];
7392
7567
  if (manifests.length > 0) {
7393
7568
  const programByFile = createPrograms(manifests.map((m) => path25.resolve(rootDir, m.filePath)));
7394
7569
  for (const manifest of manifests) {
@@ -7397,11 +7572,28 @@ async function generateTaskArtifacts(manifests, rootDir, dist) {
7397
7572
  name: manifest.name,
7398
7573
  filePath: manifest.filePath
7399
7574
  });
7400
- if (result) {
7401
- metadata.push({ ...result, taskName: manifest.name });
7575
+ if (!result) {
7576
+ declarationErrors.push(
7577
+ `"${manifest.name}" (${manifest.filePath}): no exported run function found`
7578
+ );
7579
+ continue;
7580
+ }
7581
+ if (!result.inputTypeName) {
7582
+ declarationErrors.push(
7583
+ `"${manifest.name}" (${manifest.filePath}): run's first parameter has no type name \u2014 declare a Payload type (an empty interface for parameterless tasks), or explicitly opt out of validation with "type Payload = unknown"`
7584
+ );
7585
+ continue;
7402
7586
  }
7587
+ metadata.push({ ...result, taskName: manifest.name });
7403
7588
  }
7404
7589
  }
7590
+ if (declarationErrors.length > 0) {
7591
+ throw new Error(
7592
+ `[faapi] ${declarationErrors.length} task(s) missing a Payload type declaration \u2014 payload validation must not be silently skipped:
7593
+ ${declarationErrors.join("\n ")}
7594
+ Every task must declare a payload type on run's first parameter; "type Payload = unknown" generates an always-passing schema (explicit no-validation opt-out).`
7595
+ );
7596
+ }
7405
7597
  const serialized = serializeTasks(manifests, dist);
7406
7598
  const tasksPath = path25.resolve(rootDir, dist, TASKS_FILE);
7407
7599
  await writeTasksModule(serialized, tasksPath);
@@ -7679,7 +7871,7 @@ async function createAppBase(options) {
7679
7871
  const taskQueue = createTaskQueue({
7680
7872
  registry: registries.task,
7681
7873
  rootDir,
7682
- config,
7874
+ config: config ?? {},
7683
7875
  resourcesDir,
7684
7876
  registries: createTaskRegistriesView(registries),
7685
7877
  // 轻量补全通道:进程内路径经 store 惰性读取(插件注册后的活引用);
@@ -7710,7 +7902,7 @@ async function createAppBase(options) {
7710
7902
  dist,
7711
7903
  cors: config?.cors ?? true,
7712
7904
  onError: config?.lifecycle?.onError,
7713
- config: config ?? void 0,
7905
+ config,
7714
7906
  wsRoutes,
7715
7907
  middlewares: config?.middlewares,
7716
7908
  injectors: config?.injectors,