@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/cli/index.js CHANGED
@@ -767,38 +767,30 @@ function setCachedMiddlewares(absPath, bundle) {
767
767
  middlewareCache.set(absPath, bundle);
768
768
  }
769
769
  async function loadMiddlewaresFile(filePath) {
770
- let module;
771
- try {
772
- module = await importWithCacheBust(filePath);
773
- } catch (err) {
774
- console.error(
775
- `[faapi] Failed to load middlewares from ${filePath}:`,
776
- err instanceof Error ? err.stack ?? err.message : err
777
- );
778
- return { middlewares: [], injectors: {} };
779
- }
770
+ const module = await importWithCacheBust(filePath);
780
771
  const middlewares = module.default ?? module.middlewares ?? [];
781
772
  if (!Array.isArray(middlewares)) {
782
- console.warn(`[faapi] middlewares.ts \u5E94\u5BFC\u51FA\u6570\u7EC4\uFF0C\u5DF2\u5FFD\u7565: ${filePath}`);
783
- return { middlewares: [], injectors: {} };
773
+ throw new TypeError(
774
+ `[faapi] middlewares.ts must export an array (default or named "middlewares"): ${filePath}`
775
+ );
784
776
  }
785
- const validMiddlewares = middlewares.filter((m) => {
777
+ const validMiddlewares = [];
778
+ for (const m of middlewares) {
786
779
  if (typeof m !== "function") {
787
- console.warn(`[faapi] \u65E0\u6548\u7684\u4E2D\u95F4\u4EF6\u9879\uFF08\u5E94\u4E3A\u51FD\u6570\uFF09\uFF0C\u5DF2\u5FFD\u7565: ${typeof m}`);
788
- return false;
780
+ throw new TypeError(
781
+ `[faapi] every middleware must be a function, got ${typeof m}: ${filePath}`
782
+ );
789
783
  }
790
- return true;
791
- });
784
+ validMiddlewares.push(m);
785
+ }
792
786
  const injectors = module.injectors ?? {};
793
787
  if (typeof injectors !== "object" || injectors === null) {
794
- console.warn(`[faapi] injectors \u5E94\u5BFC\u51FA\u5BF9\u8C61\uFF0C\u5DF2\u5FFD\u7565: ${filePath}`);
795
- return { middlewares: validMiddlewares, injectors: {} };
788
+ throw new TypeError(`[faapi] "injectors" export must be an object: ${filePath}`);
796
789
  }
797
790
  const validInjectors = {};
798
791
  for (const [name, injector] of Object.entries(injectors)) {
799
792
  if (typeof injector !== "function") {
800
- console.warn(`[faapi] \u6CE8\u5165\u5668 ${name} \u5E94\u4E3A\u51FD\u6570\uFF0C\u5DF2\u5FFD\u7565`);
801
- continue;
793
+ throw new TypeError(`[faapi] injector "${name}" must be a function: ${filePath}`);
802
794
  }
803
795
  validInjectors[name] = injector;
804
796
  }
@@ -813,10 +805,16 @@ async function loadMergedMiddlewares(middlewarePaths) {
813
805
  if (bundle === void 0) {
814
806
  let loading = inFlight.get(absMwPath);
815
807
  if (!loading) {
816
- loading = loadMiddlewaresFile(absMwPath).then((result) => {
817
- setCachedMiddlewares(absMwPath, result);
818
- return result;
819
- });
808
+ loading = loadMiddlewaresFile(absMwPath).then(
809
+ (result) => {
810
+ setCachedMiddlewares(absMwPath, result);
811
+ return result;
812
+ },
813
+ (err) => {
814
+ inFlight.delete(absMwPath);
815
+ throw err;
816
+ }
817
+ );
820
818
  inFlight.set(absMwPath, loading);
821
819
  }
822
820
  bundle = await loading;
@@ -3629,25 +3627,27 @@ function detectIgnoredMetaFields(source, strictHit) {
3629
3627
  for (const line of source.split("\n")) {
3630
3628
  const trimmed = line.trim();
3631
3629
  if (trimmed.startsWith("//")) continue;
3630
+ if (trimmed.startsWith("*") || trimmed.startsWith("/*") || trimmed.endsWith("*/")) continue;
3632
3631
  for (const field of ALL_META_FIELDS) {
3633
3632
  if (new RegExp(`\\b${field}\\s*:`).test(trimmed)) declared.add(field);
3634
3633
  }
3635
3634
  }
3636
3635
  return ALL_META_FIELDS.filter((field) => declared.has(field) && !strictHit[field]);
3637
3636
  }
3638
- function warnIgnoredMetaFields(name, fields) {
3639
- for (const field of fields) {
3637
+ function throwIgnoredMetaFields(name, fields) {
3638
+ if (fields.length === 0) return;
3639
+ const details = fields.map((field) => {
3640
3640
  if (STRING_META_FIELDS.includes(field)) {
3641
- console.warn(
3642
- `[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.`
3643
- );
3644
- } else {
3645
- const consequence = field === "timeoutMs" ? " and the task will run in-process with no timeout" : "";
3646
- console.warn(
3647
- `[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.`
3648
- );
3641
+ 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.`;
3649
3642
  }
3650
- }
3643
+ 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";
3644
+ 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.`;
3645
+ });
3646
+ throw new Error(
3647
+ `[faapi] Task "${name}" declares meta field(s) with non-literal values:
3648
+ ${details.join("\n ")}
3649
+ 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.`
3650
+ );
3651
3651
  }
3652
3652
  function parseNumericLiteral(raw) {
3653
3653
  return Number(raw.replace(/_/g, ""));
@@ -3715,7 +3715,7 @@ async function scanTasks(rootDir, patterns) {
3715
3715
  timeoutMs: timeoutMs !== void 0,
3716
3716
  graceMs: graceMs !== void 0
3717
3717
  };
3718
- warnIgnoredMetaFields(name, detectIgnoredMetaFields(source, strictHit));
3718
+ throwIgnoredMetaFields(name, detectIgnoredMetaFields(source, strictHit));
3719
3719
  tasks.push(manifest);
3720
3720
  }
3721
3721
  return tasks.sort((a, b) => a.name.localeCompare(b.name));
@@ -3830,6 +3830,7 @@ function generateTaskSchemaFileSource(sources, resolveType, helpersImportPath) {
3830
3830
  }
3831
3831
  async function generateTaskArtifacts(manifests, rootDir, dist) {
3832
3832
  const metadata = [];
3833
+ const declarationErrors = [];
3833
3834
  if (manifests.length > 0) {
3834
3835
  const programByFile = createPrograms(manifests.map((m) => path17.resolve(rootDir, m.filePath)));
3835
3836
  for (const manifest of manifests) {
@@ -3838,11 +3839,28 @@ async function generateTaskArtifacts(manifests, rootDir, dist) {
3838
3839
  name: manifest.name,
3839
3840
  filePath: manifest.filePath
3840
3841
  });
3841
- if (result) {
3842
- metadata.push({ ...result, taskName: manifest.name });
3842
+ if (!result) {
3843
+ declarationErrors.push(
3844
+ `"${manifest.name}" (${manifest.filePath}): no exported run function found`
3845
+ );
3846
+ continue;
3843
3847
  }
3848
+ if (!result.inputTypeName) {
3849
+ declarationErrors.push(
3850
+ `"${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"`
3851
+ );
3852
+ continue;
3853
+ }
3854
+ metadata.push({ ...result, taskName: manifest.name });
3844
3855
  }
3845
3856
  }
3857
+ if (declarationErrors.length > 0) {
3858
+ throw new Error(
3859
+ `[faapi] ${declarationErrors.length} task(s) missing a Payload type declaration \u2014 payload validation must not be silently skipped:
3860
+ ${declarationErrors.join("\n ")}
3861
+ 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).`
3862
+ );
3863
+ }
3846
3864
  const serialized = serializeTasks(manifests, dist);
3847
3865
  const tasksPath = path17.resolve(rootDir, dist, TASKS_FILE);
3848
3866
  await writeTasksModule(serialized, tasksPath);
@@ -4023,11 +4041,7 @@ async function ensureMiddlewaresCompiled(middlewarePaths, rootDir) {
4023
4041
  if (!dist) return;
4024
4042
  for (const mwPath of middlewarePaths) {
4025
4043
  const sourcePath = prodPathToSourcePath(mwPath, rootDir, dist);
4026
- try {
4027
- await ensureCompiled(sourcePath, rootDir, dist);
4028
- } catch (err) {
4029
- console.error(`[faapi] Failed to compile middleware source ${sourcePath}:`, err);
4030
- }
4044
+ await ensureCompiled(sourcePath, rootDir, dist);
4031
4045
  }
4032
4046
  }
4033
4047
  function prodSourcePathToProductPath(sourceAbsPath, rootDir, dist) {
@@ -6944,17 +6958,14 @@ function formatEntry(entry) {
6944
6958
  const plain = Object.fromEntries(
6945
6959
  Object.entries(entry.fields).map(([k, v]) => [k, serializeFieldValue(v)])
6946
6960
  );
6947
- try {
6948
- line += ` ${JSON.stringify(plain)}`;
6949
- } catch (err) {
6950
- line += ` [unserializable fields: ${err instanceof Error ? err.message : String(err)}]`;
6951
- }
6961
+ line += ` ${stringifyJson(plain)}`;
6952
6962
  }
6953
6963
  return line;
6954
6964
  }
6955
6965
  var init_formatEntry = __esm({
6956
6966
  "src/logger/formatEntry.ts"() {
6957
6967
  "use strict";
6968
+ init_stringifyJson();
6958
6969
  }
6959
6970
  });
6960
6971
 
@@ -6970,7 +6981,14 @@ function createFileLogSink(options) {
6970
6981
  };
6971
6982
  const openStream = (name) => {
6972
6983
  const stream = fs18.createWriteStream(path24.join(options.dir, `${name}.log`), { flags: "a" });
6973
- stream.on("error", () => {
6984
+ let streamErrorLogged = false;
6985
+ stream.on("error", (err) => {
6986
+ if (streamErrorLogged) return;
6987
+ streamErrorLogged = true;
6988
+ console.error(
6989
+ `[faapi] log file stream "${name}.log" failed \u2014 file logging on this stream stopped (subsequent errors suppressed). Check disk space / permissions for ${options.dir}:`,
6990
+ err
6991
+ );
6974
6992
  });
6975
6993
  streams.set(name, stream);
6976
6994
  };
@@ -9317,7 +9335,8 @@ async function sendErrorResponse(err, meta, res, onError, ctx) {
9317
9335
  if (onError && ctx) {
9318
9336
  try {
9319
9337
  await onError(err, ctx);
9320
- } catch {
9338
+ } catch (hookErr) {
9339
+ console.error("[faapi] lifecycle onError hook threw:", hookErr);
9321
9340
  }
9322
9341
  }
9323
9342
  }
@@ -9461,19 +9480,6 @@ function resolveWorkerEntryUrl() {
9461
9480
  workerEntryUrl = existsSync3(fileURLToPath(js)) ? js : new URL("./workerEntry.ts", import.meta.url);
9462
9481
  return workerEntryUrl;
9463
9482
  }
9464
- function safeConfig(config) {
9465
- if (config === void 0 || config === null) return config;
9466
- try {
9467
- structuredClone(config);
9468
- return config;
9469
- } catch {
9470
- }
9471
- try {
9472
- return JSON.parse(JSON.stringify(config));
9473
- } catch {
9474
- return void 0;
9475
- }
9476
- }
9477
9483
  function reviveError(payload) {
9478
9484
  const err = new Error(payload?.message ?? "task worker error");
9479
9485
  if (payload?.name !== void 0) err.name = payload.name;
@@ -9542,6 +9548,53 @@ async function runTaskInWorker(options) {
9542
9548
  worker.on(
9543
9549
  "message",
9544
9550
  (msg) => {
9551
+ if (msg?.type === "tasks-call") {
9552
+ const { seq, method, args } = msg;
9553
+ const reply = (body) => {
9554
+ try {
9555
+ worker.postMessage({ type: "tasks-result", seq, ...body });
9556
+ } catch (err) {
9557
+ try {
9558
+ worker.postMessage({
9559
+ type: "tasks-result",
9560
+ seq,
9561
+ ok: false,
9562
+ error: {
9563
+ name: "Error",
9564
+ message: `taskCtx.tasks.${method} result is not cloneable: ${String(err)}`,
9565
+ stack: void 0,
9566
+ props: {}
9567
+ }
9568
+ });
9569
+ } catch {
9570
+ }
9571
+ }
9572
+ };
9573
+ if (!options.onTasksCall) {
9574
+ reply({
9575
+ ok: false,
9576
+ error: {
9577
+ name: "Error",
9578
+ message: `taskCtx.tasks.${method} is unavailable: no host handler was provided for this execution`,
9579
+ stack: void 0,
9580
+ props: {}
9581
+ }
9582
+ });
9583
+ return;
9584
+ }
9585
+ options.onTasksCall(method, args ?? []).then((value) => reply({ ok: true, value })).catch((err) => {
9586
+ reply({
9587
+ ok: false,
9588
+ error: {
9589
+ name: err instanceof Error ? err.name : "Error",
9590
+ message: err instanceof Error ? err.message : String(err),
9591
+ stack: err instanceof Error ? err.stack : void 0,
9592
+ props: {}
9593
+ }
9594
+ });
9595
+ });
9596
+ return;
9597
+ }
9545
9598
  if (phase === "grace") {
9546
9599
  if (msg?.type === "done") {
9547
9600
  finish(
@@ -9588,7 +9641,6 @@ async function runTaskInWorker(options) {
9588
9641
  type: "run",
9589
9642
  payload,
9590
9643
  taskCtx: {
9591
- config: safeConfig(taskCtx.config),
9592
9644
  job: taskCtx.job
9593
9645
  },
9594
9646
  registries: options.registries,
@@ -9597,9 +9649,7 @@ async function runTaskInWorker(options) {
9597
9649
  });
9598
9650
  } catch (err) {
9599
9651
  finish(
9600
- () => reject(
9601
- new Error(`Task "${taskCtx.job.name}" payload/config is not cloneable: ${String(err)}`)
9602
- )
9652
+ () => reject(new Error(`Task "${taskCtx.job.name}" payload is not cloneable: ${String(err)}`))
9603
9653
  );
9604
9654
  }
9605
9655
  });
@@ -9650,13 +9700,14 @@ function createTaskQueue(deps) {
9650
9700
  const loadTaskModule = deps.loadTaskModule ?? (async (filePath) => await import(filePath));
9651
9701
  const loadPayloadSchema = deps.loadPayloadSchema ?? (async (filePath) => {
9652
9702
  const zodPath = path28.join(path28.dirname(filePath), "zod.js");
9653
- try {
9654
- const mod = await import(zodPath);
9655
- const schemaKey = Object.keys(mod).find((k) => k.endsWith("Schema"));
9656
- return schemaKey ? mod[schemaKey] : void 0;
9657
- } catch {
9658
- return void 0;
9703
+ const mod = await import(zodPath);
9704
+ const schemaKey = Object.keys(mod).find((k) => k.endsWith("Schema"));
9705
+ if (!schemaKey) {
9706
+ throw new Error(
9707
+ `[faapi] Task payload schema artifact has no *Schema export: ${zodPath} \u2014 re-run "faapi dev" / "faapi build" to regenerate task artifacts.`
9708
+ );
9659
9709
  }
9710
+ return mod[schemaKey];
9660
9711
  });
9661
9712
  function assertKnownTask(name) {
9662
9713
  const meta = registry.get(name);
@@ -9665,6 +9716,54 @@ function createTaskQueue(deps) {
9665
9716
  throw new Error(`[faapi] Unknown task "${name}". Registered tasks: ${known}`);
9666
9717
  }
9667
9718
  }
9719
+ function assertGroupSupport() {
9720
+ if (!driver.groups) {
9721
+ throw new Error(
9722
+ "[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."
9723
+ );
9724
+ }
9725
+ }
9726
+ async function maybeFireCompletion(snap) {
9727
+ if (!driver.groups) return;
9728
+ if (!snap.onComplete || snap.completionEnqueued || snap.settled < snap.total) return;
9729
+ const summary = {
9730
+ groupId: snap.groupId,
9731
+ task: snap.task,
9732
+ total: snap.total,
9733
+ done: snap.done,
9734
+ failed: snap.failed,
9735
+ cancelled: snap.cancelled,
9736
+ settled: snap.settled
9737
+ };
9738
+ try {
9739
+ await enqueueCore(snap.onComplete, summary, {
9740
+ dedupId: GROUP_COMPLETION_DEDUP(snap.groupId)
9741
+ });
9742
+ await driver.groups.markCompletionEnqueued(snap.groupId);
9743
+ } catch (err) {
9744
+ console.error(
9745
+ `[faapi] task group "${snap.groupId}" completion enqueue failed (group is settled; re-invoke enqueueGroup with the same groupId to re-arm the callback):`,
9746
+ err
9747
+ );
9748
+ }
9749
+ }
9750
+ async function settleGroup(record, outcome, willRetry) {
9751
+ const groupId = record.groupId;
9752
+ if (!groupId || !driver.groups) return;
9753
+ if (outcome !== "done" && willRetry) return;
9754
+ try {
9755
+ const snap = await driver.groups.settle(groupId, record.id, outcome);
9756
+ if (outcome === "failed" && snap.onFailure === "fail-fast") {
9757
+ await maybeFireCompletion(await driver.groups.cancelRemaining(groupId));
9758
+ }
9759
+ await maybeFireCompletion(snap);
9760
+ } catch (err) {
9761
+ console.error(
9762
+ `[faapi] task group accounting failed for "${groupId}" (job ${record.id}, outcome ${outcome}):`,
9763
+ err
9764
+ );
9765
+ }
9766
+ }
9668
9767
  function snapshotRegistries() {
9669
9768
  return {
9670
9769
  agents: registriesView.agent.listAgents().map((core) => registriesView.agent.getAgentEntry(core.name)).filter((entry) => entry !== void 0),
@@ -9675,14 +9774,13 @@ function createTaskQueue(deps) {
9675
9774
  async function validatePayload(name, payload) {
9676
9775
  if (!schemaCache.has(name)) {
9677
9776
  const meta = registry.get(name);
9678
- schemaCache.set(
9679
- name,
9680
- await loadPayloadSchema(path28.resolve(rootDir, meta.filePath)).catch(() => void 0)
9681
- );
9777
+ schemaCache.set(name, await loadPayloadSchema(path28.resolve(rootDir, meta.filePath)));
9682
9778
  }
9683
9779
  const schema = schemaCache.get(name);
9684
9780
  if (!schema || typeof schema.safeParse !== "function") {
9685
- return payload;
9781
+ throw new Error(
9782
+ `[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.`
9783
+ );
9686
9784
  }
9687
9785
  const result = schema.safeParse(payload);
9688
9786
  if (!result.success) {
@@ -9704,11 +9802,13 @@ function createTaskQueue(deps) {
9704
9802
  attempts: 0,
9705
9803
  createdAt: Date.now()
9706
9804
  };
9805
+ if (job.groupId !== void 0) record.groupId = job.groupId;
9707
9806
  record.attempts = job.attempt;
9708
9807
  transitionStatus(record, "running");
9709
9808
  delete record.progress;
9710
9809
  record.error = void 0;
9711
9810
  records.set(job.id, record);
9811
+ const willRetry = job.attempt <= (meta.retries ?? 0);
9712
9812
  try {
9713
9813
  let result;
9714
9814
  if (meta.timeoutMs !== void 0 && meta.timeoutMs > 0) {
@@ -9716,7 +9816,8 @@ function createTaskQueue(deps) {
9716
9816
  taskModulePath: path28.resolve(rootDir, meta.filePath),
9717
9817
  payload: job.payload,
9718
9818
  taskCtx: {
9719
- config: deps.config,
9819
+ // 隔离任务不传 config:worker 线程不接收进程配置(config 含函数字段
9820
+ // 不可结构化克隆,框架不做降级传递)——任务数据经 payload 显式传入
9720
9821
  resourcesDir: deps.resourcesDir,
9721
9822
  job: { id: job.id, name: job.name, attempt: job.attempt }
9722
9823
  },
@@ -9737,7 +9838,16 @@ function createTaskQueue(deps) {
9737
9838
  onProgress: (value) => {
9738
9839
  if (record.status === "running") record.progress = value;
9739
9840
  },
9740
- onLog: writeLogEntry
9841
+ onLog: writeLogEntry,
9842
+ // 隔离路径的 taskCtx.tasks 为 postMessage RPC 代理——宿主侧按方法名
9843
+ // 分派到队列本体(与 ctx.tasks / app.tasks 同一实例),结果/错误按 seq 回传
9844
+ onTasksCall: async (method, args) => {
9845
+ if (!TASK_CLIENT_METHODS.has(method)) {
9846
+ throw new Error(`[faapi] Unknown task client method "${method}"`);
9847
+ }
9848
+ const fn = queue[method];
9849
+ return fn.apply(queue, args);
9850
+ }
9741
9851
  });
9742
9852
  } else {
9743
9853
  let mod = moduleCache2.get(job.name);
@@ -9750,6 +9860,8 @@ function createTaskQueue(deps) {
9750
9860
  }
9751
9861
  const taskCtx = {
9752
9862
  signal: job.signal,
9863
+ // 活引用:与 ctx.tasks / app.tasks 同一 app 实例队列
9864
+ tasks: queue,
9753
9865
  config: deps.config,
9754
9866
  job: { id: job.id, name: job.name, attempt: job.attempt },
9755
9867
  registries: registriesView,
@@ -9767,23 +9879,27 @@ function createTaskQueue(deps) {
9767
9879
  transitionStatus(record, "done");
9768
9880
  record.result = result;
9769
9881
  evictFinishedRecords();
9882
+ await settleGroup(record, "done", willRetry);
9770
9883
  return result;
9771
9884
  } catch (err) {
9772
9885
  const cancelled = err instanceof TaskCancelledError || job.signal.aborted;
9773
- transitionStatus(record, cancelled ? "cancelled" : "failed");
9886
+ const outcome = cancelled ? "cancelled" : "failed";
9887
+ transitionStatus(record, outcome);
9774
9888
  record.error = err instanceof Error ? err.message : String(err);
9775
9889
  evictFinishedRecords();
9890
+ await settleGroup(record, outcome, willRetry);
9776
9891
  if (deps.onFailed) {
9777
9892
  void Promise.resolve().then(
9778
9893
  () => deps.onFailed({
9779
9894
  task: job.name,
9780
9895
  jobId: job.id,
9781
9896
  attempt: job.attempt,
9782
- willRetry: job.attempt <= (meta.retries ?? 0),
9897
+ willRetry,
9783
9898
  cancelled,
9784
9899
  error: record.error
9785
9900
  })
9786
- ).catch(() => {
9901
+ ).catch((hookErr) => {
9902
+ console.error(`[faapi] task onFailed hook threw for "${job.name}":`, hookErr);
9787
9903
  });
9788
9904
  }
9789
9905
  throw err;
@@ -9797,35 +9913,86 @@ function createTaskQueue(deps) {
9797
9913
  });
9798
9914
  }
9799
9915
  }
9916
+ async function enqueueCore(name, payload, opts) {
9917
+ if (stopped) {
9918
+ throw new Error("[faapi] Task queue is stopped and no longer accepts jobs");
9919
+ }
9920
+ assertKnownTask(name);
9921
+ const data = await validatePayload(name, payload);
9922
+ const meta = registry.get(name);
9923
+ const id = await driver.enqueue(name, data, {
9924
+ delayMs: opts?.delayMs,
9925
+ retries: meta.retries ?? 0,
9926
+ dedupId: opts?.dedupId,
9927
+ // 执行硬限预算透传驱动(pgboss 映射 expireInSeconds):未声明 timeoutMs 时
9928
+ // 两字段为 undefined,驱动用自身兜底
9929
+ timeoutMs: meta.timeoutMs,
9930
+ graceMs: meta.graceMs,
9931
+ ...opts?.groupId !== void 0 ? { groupId: opts.groupId } : {}
9932
+ });
9933
+ if (!records.has(id)) {
9934
+ records.set(id, {
9935
+ id,
9936
+ name,
9937
+ payload: data,
9938
+ status: "pending",
9939
+ attempts: 0,
9940
+ ...opts?.groupId !== void 0 ? { groupId: opts.groupId } : {},
9941
+ createdAt: Date.now(),
9942
+ ...opts?.delayMs ? { runAt: Date.now() + opts.delayMs } : {}
9943
+ });
9944
+ }
9945
+ return id;
9946
+ }
9800
9947
  const queue = {
9801
9948
  async enqueue(name, payload = {}, opts) {
9949
+ const id = await enqueueCore(name, payload, {
9950
+ delayMs: opts?.delayMs,
9951
+ dedupId: opts?.dedupId
9952
+ });
9953
+ return { id };
9954
+ },
9955
+ async enqueueGroup(name, payloads, opts) {
9802
9956
  if (stopped) {
9803
9957
  throw new Error("[faapi] Task queue is stopped and no longer accepts jobs");
9804
9958
  }
9959
+ assertGroupSupport();
9805
9960
  assertKnownTask(name);
9806
- const data = await validatePayload(name, payload);
9807
- const meta = registry.get(name);
9808
- const id = await driver.enqueue(name, data, {
9809
- delayMs: opts?.delayMs,
9810
- retries: meta.retries ?? 0,
9811
- dedupId: opts?.dedupId,
9812
- // 执行硬限预算透传驱动(pgboss 映射 expireInSeconds):未声明 timeoutMs 时
9813
- // 两字段为 undefined,驱动用自身兜底
9814
- timeoutMs: meta.timeoutMs,
9815
- graceMs: meta.graceMs
9961
+ if (!Array.isArray(payloads) || payloads.length === 0) {
9962
+ throw new Error(
9963
+ "[faapi] enqueueGroup requires a non-empty payloads array \u2014 a group without members can never settle."
9964
+ );
9965
+ }
9966
+ const onComplete = opts?.onComplete;
9967
+ if (onComplete !== void 0) assertKnownTask(onComplete);
9968
+ const onFailure = opts?.onFailure ?? "run-to-completion";
9969
+ const validated = [];
9970
+ for (const p of payloads) {
9971
+ validated.push(await validatePayload(name, p));
9972
+ }
9973
+ const groupId = opts?.groupId ?? crypto.randomUUID();
9974
+ await driver.groups.create({
9975
+ id: groupId,
9976
+ task: name,
9977
+ total: validated.length,
9978
+ ...onComplete !== void 0 ? { onComplete } : {},
9979
+ onFailure
9816
9980
  });
9817
- if (!records.has(id)) {
9818
- records.set(id, {
9819
- id,
9820
- name,
9821
- payload: data,
9822
- status: "pending",
9823
- attempts: 0,
9824
- createdAt: Date.now(),
9825
- ...opts?.delayMs ? { runAt: Date.now() + opts.delayMs } : {}
9981
+ const jobs = [];
9982
+ for (let i = 0; i < validated.length; i++) {
9983
+ const id = await enqueueCore(name, validated[i], {
9984
+ delayMs: opts?.delayMs,
9985
+ dedupId: GROUP_MEMBER_DEDUP(groupId, i + 1),
9986
+ groupId
9826
9987
  });
9988
+ jobs.push({ id });
9827
9989
  }
9828
- return { id };
9990
+ await maybeFireCompletion(await driver.groups.get(groupId));
9991
+ return { groupId, jobs };
9992
+ },
9993
+ async getGroup(groupId) {
9994
+ assertGroupSupport();
9995
+ return driver.groups.get(groupId);
9829
9996
  },
9830
9997
  list(name) {
9831
9998
  const snapshot = [];
@@ -9852,6 +10019,7 @@ function createTaskQueue(deps) {
9852
10019
  status: record.status,
9853
10020
  attempts: record.attempts,
9854
10021
  createdAt: record.createdAt,
10022
+ ...record.groupId !== void 0 ? { groupId: record.groupId } : {},
9855
10023
  ...record.result !== void 0 ? { result: record.result } : {},
9856
10024
  ...record.error !== void 0 ? { error: record.error } : {},
9857
10025
  ...record.runAt !== void 0 ? { runAt: record.runAt } : {}
@@ -9871,8 +10039,11 @@ function createTaskQueue(deps) {
9871
10039
  }
9872
10040
  await driver.cancel(name, id);
9873
10041
  const record = records.get(id);
9874
- if (record) transitionStatus(record, "cancelled");
9875
- evictFinishedRecords();
10042
+ if (record) {
10043
+ transitionStatus(record, "cancelled");
10044
+ evictFinishedRecords();
10045
+ await settleGroup(record, "cancelled", false);
10046
+ }
9876
10047
  },
9877
10048
  async retry(name, id) {
9878
10049
  if (!driver.retry) {
@@ -9882,7 +10053,18 @@ function createTaskQueue(deps) {
9882
10053
  }
9883
10054
  await driver.retry(name, id);
9884
10055
  const record = records.get(id);
9885
- if (record) transitionStatus(record, "pending");
10056
+ if (record) {
10057
+ const wasTerminal = TERMINAL_STATUSES.has(record.status);
10058
+ transitionStatus(record, "pending");
10059
+ if (wasTerminal && record.groupId && driver.groups) {
10060
+ await driver.groups.unsettle(record.groupId, record.id).catch((err) => {
10061
+ console.error(
10062
+ `[faapi] task group unsettle failed for "${record.groupId}" (job ${record.id}):`,
10063
+ err
10064
+ );
10065
+ });
10066
+ }
10067
+ }
9886
10068
  },
9887
10069
  async start() {
9888
10070
  if (stopped) {
@@ -9909,7 +10091,7 @@ function createTaskQueue(deps) {
9909
10091
  };
9910
10092
  return queue;
9911
10093
  }
9912
- var TERMINAL_STATUSES, MAX_FINISHED_RECORDS;
10094
+ var TERMINAL_STATUSES, GROUP_MEMBER_DEDUP, GROUP_COMPLETION_DEDUP, TASK_CLIENT_METHODS, MAX_FINISHED_RECORDS;
9913
10095
  var init_taskQueue = __esm({
9914
10096
  "src/task/taskQueue.ts"() {
9915
10097
  "use strict";
@@ -9918,6 +10100,17 @@ var init_taskQueue = __esm({
9918
10100
  init_registries();
9919
10101
  init_logger();
9920
10102
  TERMINAL_STATUSES = /* @__PURE__ */ new Set(["done", "failed", "cancelled"]);
10103
+ GROUP_MEMBER_DEDUP = (groupId, index) => `faapi-group:${groupId}:${index}`;
10104
+ GROUP_COMPLETION_DEDUP = (groupId) => `faapi-group:${groupId}:complete`;
10105
+ TASK_CLIENT_METHODS = /* @__PURE__ */ new Set([
10106
+ "enqueue",
10107
+ "enqueueGroup",
10108
+ "list",
10109
+ "listQueued",
10110
+ "cancel",
10111
+ "retry",
10112
+ "getGroup"
10113
+ ]);
9921
10114
  MAX_FINISHED_RECORDS = 1e3;
9922
10115
  }
9923
10116
  });
@@ -10041,7 +10234,7 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
10041
10234
  const upgradeWrappers = [];
10042
10235
  const failures = [];
10043
10236
  if (!declarations || declarations.length === 0) {
10044
- return { handlerWrappers, upgradeWrappers, failures };
10237
+ return { handlerWrappers, upgradeWrappers };
10045
10238
  }
10046
10239
  const fullCtx = {
10047
10240
  ...ctx,
@@ -10068,7 +10261,7 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
10068
10261
  }
10069
10262
  if (enable === false) continue;
10070
10263
  if (loaded.has(specifier)) {
10071
- console.warn(`! Plugin already loaded: ${specifier}, skipping`);
10264
+ failures.push({ specifier, reason: "duplicate plugin declaration" });
10072
10265
  continue;
10073
10266
  }
10074
10267
  loaded.add(specifier);
@@ -10089,12 +10282,12 @@ async function loadPlugins(declarations, ctx, rootDir, dist) {
10089
10282
  }
10090
10283
  }
10091
10284
  if (failures.length > 0) {
10092
- console.error(
10285
+ throw new Error(
10093
10286
  `[faapi] ${failures.length} plugin(s) failed to load:
10094
10287
  ` + failures.map((f) => ` - ${f.specifier}: ${f.reason}`).join("\n")
10095
10288
  );
10096
10289
  }
10097
- return { handlerWrappers, upgradeWrappers, failures };
10290
+ return { handlerWrappers, upgradeWrappers };
10098
10291
  }
10099
10292
  function resolveLocalPluginSource(specifier, baseDir) {
10100
10293
  const base = path29.isAbsolute(specifier) ? specifier : path29.resolve(baseDir, specifier);
@@ -10428,7 +10621,7 @@ async function createAppBase(options) {
10428
10621
  const taskQueue = createTaskQueue({
10429
10622
  registry: registries.task,
10430
10623
  rootDir,
10431
- config,
10624
+ config: config ?? {},
10432
10625
  resourcesDir,
10433
10626
  registries: createTaskRegistriesView(registries),
10434
10627
  // 轻量补全通道:进程内路径经 store 惰性读取(插件注册后的活引用);
@@ -10459,7 +10652,7 @@ async function createAppBase(options) {
10459
10652
  dist,
10460
10653
  cors: config?.cors ?? true,
10461
10654
  onError: config?.lifecycle?.onError,
10462
- config: config ?? void 0,
10655
+ config,
10463
10656
  wsRoutes,
10464
10657
  middlewares: config?.middlewares,
10465
10658
  injectors: config?.injectors,