@nmakarov/cli-toolkit 0.21.0 → 0.23.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 (68) hide show
  1. package/dist/args.cjs +1 -4
  2. package/dist/args.cjs.map +1 -1
  3. package/dist/args.js +1 -1
  4. package/dist/args.js.map +1 -1
  5. package/dist/cli-runner.cjs +1252 -604
  6. package/dist/cli-runner.cjs.map +1 -1
  7. package/dist/cli-runner.js +1299 -650
  8. package/dist/cli-runner.js.map +1 -1
  9. package/dist/db.cjs +85 -157
  10. package/dist/db.cjs.map +1 -1
  11. package/dist/db.js +84 -150
  12. package/dist/db.js.map +1 -1
  13. package/dist/errors.cjs +2 -2
  14. package/dist/errors.cjs.map +1 -1
  15. package/dist/errors.js +2 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/filedatabase.cjs +19 -19
  18. package/dist/filedatabase.cjs.map +1 -1
  19. package/dist/filedatabase.js +19 -16
  20. package/dist/filedatabase.js.map +1 -1
  21. package/dist/http-client.cjs +9 -11
  22. package/dist/http-client.cjs.map +1 -1
  23. package/dist/http-client.js +10 -9
  24. package/dist/http-client.js.map +1 -1
  25. package/dist/http-client2.cjs +34 -37
  26. package/dist/http-client2.cjs.map +1 -1
  27. package/dist/http-client2.js +34 -34
  28. package/dist/http-client2.js.map +1 -1
  29. package/dist/index.cjs +1739 -720
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +1746 -721
  32. package/dist/index.js.map +1 -1
  33. package/dist/init.cjs +81 -68
  34. package/dist/init.cjs.map +1 -1
  35. package/dist/init.js +96 -82
  36. package/dist/init.js.map +1 -1
  37. package/dist/logger.cjs +5 -5
  38. package/dist/logger.cjs.map +1 -1
  39. package/dist/logger.js +5 -4
  40. package/dist/logger.js.map +1 -1
  41. package/dist/mock-server.cjs +21 -33
  42. package/dist/mock-server.cjs.map +1 -1
  43. package/dist/mock-server.js +21 -28
  44. package/dist/mock-server.js.map +1 -1
  45. package/dist/params.cjs +6 -9
  46. package/dist/params.cjs.map +1 -1
  47. package/dist/params.js +6 -6
  48. package/dist/params.js.map +1 -1
  49. package/dist/s3.cjs +286 -0
  50. package/dist/s3.cjs.map +1 -0
  51. package/dist/s3.js +273 -0
  52. package/dist/s3.js.map +1 -0
  53. package/dist/screen.cjs +34 -39
  54. package/dist/screen.cjs.map +1 -1
  55. package/dist/screen.js +48 -46
  56. package/dist/screen.js.map +1 -1
  57. package/dist/tasks.cjs +1354 -501
  58. package/dist/tasks.cjs.map +1 -1
  59. package/dist/tasks.js +1369 -527
  60. package/dist/tasks.js.map +1 -1
  61. package/dist/utils.cjs +7 -8
  62. package/dist/utils.cjs.map +1 -1
  63. package/dist/utils.js +6 -6
  64. package/dist/utils.js.map +1 -1
  65. package/package.json +32 -47
  66. package/scripts/ssm/{parse-cli.ts → parse-cli.js} +4 -4
  67. package/scripts/ssm/{ssm-admin.ts → ssm-admin.js} +12 -12
  68. package/scripts/ssm/{ssm-pull.ts → ssm-pull.js} +10 -13
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
3
2
  var __create = Object.create;
4
3
  var __defProp = Object.defineProperty;
5
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -30,7 +29,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
29
  mod
31
30
  ));
32
31
 
33
- // src/screen/components.ts
32
+ // src/screen/components.js
34
33
  function getScreenWidth(maxWidth = null) {
35
34
  const terminalWidth = process.stdout.columns || 80;
36
35
  const availableWidth = Math.max(20, terminalWidth - 4);
@@ -104,14 +103,13 @@ function ScreenFooter({ lines, textStyle }) {
104
103
  }
105
104
  var import_react, import_ink;
106
105
  var init_components = __esm({
107
- "src/screen/components.ts"() {
108
- "use strict";
106
+ "src/screen/components.js"() {
109
107
  import_react = require("react");
110
108
  import_ink = require("ink");
111
109
  }
112
110
  });
113
111
 
114
- // src/screen/list-components.ts
112
+ // src/screen/list-components.js
115
113
  function MultiColumnListComponent({ items, ctx, selectedIndexRef }) {
116
114
  const [, forceUpdate] = (0, import_react2.useState)({});
117
115
  const termWidth = (process.stdout.columns || 80) - 8;
@@ -263,11 +261,13 @@ function MultiColumnListWithPreviewComponent({
263
261
  ...previewRows
264
262
  );
265
263
  }
266
- function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sortable = false, maxHeight, sortHighlightStyle, selectionMarker = " " }) {
264
+ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sortable = false, maxHeight, sortHighlightStyle, selectionMarker = " ", onSelectionChange }) {
267
265
  const [, forceUpdate] = (0, import_react2.useState)({});
268
266
  const [sortOrder, setSortOrder] = (0, import_react2.useState)("none");
269
267
  const [scrollOffset, setScrollOffset] = (0, import_react2.useState)(0);
270
268
  const scrollStateRef = (0, import_react2.useRef)({ scrollOffset: 0, maxHeight: 0, totalItems: 0 });
269
+ const itemsRef = (0, import_react2.useRef)(items);
270
+ itemsRef.current = items;
271
271
  const defaultGetTitle = (item) => {
272
272
  return getTitle ? getTitle(item) : typeof item.value === "string" ? item.value : item.value?.title || item.name;
273
273
  };
@@ -281,18 +281,24 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
281
281
  return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
282
282
  }
283
283
  }) : items;
284
+ const displayItemsRef = (0, import_react2.useRef)(displayItems);
285
+ displayItemsRef.current = displayItems;
284
286
  const effectiveMaxHeight = maxHeight || displayItems.length;
285
- const canScroll = displayItems.length > effectiveMaxHeight;
287
+ const _canScroll = displayItems.length > effectiveMaxHeight;
286
288
  const maxScrollOffset = Math.max(0, displayItems.length - effectiveMaxHeight);
287
289
  const clampedScrollOffset = Math.min(Math.max(0, scrollOffset), maxScrollOffset);
288
290
  const visibleItems = displayItems.slice(clampedScrollOffset, clampedScrollOffset + effectiveMaxHeight);
289
291
  const canScrollUp = clampedScrollOffset > 0;
290
292
  const canScrollDown = clampedScrollOffset < maxScrollOffset;
291
293
  scrollStateRef.current = { scrollOffset, maxHeight: effectiveMaxHeight, totalItems: displayItems.length };
294
+ const onSelectionChangeRef = (0, import_react2.useRef)(onSelectionChange);
295
+ onSelectionChangeRef.current = onSelectionChange;
292
296
  (0, import_react2.useEffect)(() => {
293
297
  ctx.setAction("moveUp", () => {
294
298
  const newIndex = Math.max(0, selectedIndexRef.current - 1);
295
299
  selectedIndexRef.current = newIndex;
300
+ const list = displayItemsRef.current;
301
+ onSelectionChangeRef.current?.(newIndex, list[newIndex]);
296
302
  const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
297
303
  const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
298
304
  const currentClampedScrollOffset = Math.min(Math.max(0, currentScrollOffset), currentMaxScrollOffset);
@@ -302,18 +308,11 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
302
308
  forceUpdate({});
303
309
  });
304
310
  ctx.setAction("moveDown", () => {
305
- const currentItems = sortable && sortOrder !== "none" ? [...items].sort((a, b) => {
306
- const titleA = titleGetter(a).toLowerCase();
307
- const titleB = titleGetter(b).toLowerCase();
308
- if (sortOrder === "asc") {
309
- return titleA < titleB ? -1 : titleA > titleB ? 1 : 0;
310
- } else {
311
- return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
312
- }
313
- }) : items;
314
- const maxIndex = currentItems.length - 1;
311
+ const currentItems = displayItemsRef.current;
312
+ const maxIndex = Math.max(0, currentItems.length - 1);
315
313
  const newIndex = Math.min(maxIndex, selectedIndexRef.current + 1);
316
314
  selectedIndexRef.current = newIndex;
315
+ onSelectionChangeRef.current?.(newIndex, currentItems[newIndex]);
317
316
  const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
318
317
  const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
319
318
  const currentClampedScrollOffset = Math.min(Math.max(0, currentScrollOffset), currentMaxScrollOffset);
@@ -323,8 +322,7 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
323
322
  forceUpdate({});
324
323
  });
325
324
  ctx.setAction("scrollUp", () => {
326
- const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
327
- const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
325
+ const { scrollOffset: currentScrollOffset } = scrollStateRef.current;
328
326
  const newScrollOffset = Math.max(0, currentScrollOffset - 1);
329
327
  setScrollOffset(newScrollOffset);
330
328
  forceUpdate({});
@@ -339,9 +337,9 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
339
337
  if (sortable) {
340
338
  ctx.setAction("toggleSort", () => {
341
339
  const nextSort = sortOrder === "none" ? "asc" : sortOrder === "asc" ? "desc" : "none";
342
- const currentSelectedItem = displayItems[selectedIndexRef.current];
340
+ const currentSelectedItem = displayItemsRef.current[selectedIndexRef.current];
343
341
  setSortOrder(nextSort);
344
- const newSortedItems = nextSort !== "none" ? [...items].sort((a, b) => {
342
+ const newSortedItems = nextSort !== "none" ? [...itemsRef.current].sort((a, b) => {
345
343
  const titleA = titleGetter(a).toLowerCase();
346
344
  const titleB = titleGetter(b).toLowerCase();
347
345
  if (nextSort === "asc") {
@@ -349,7 +347,7 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
349
347
  } else {
350
348
  return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
351
349
  }
352
- }) : items;
350
+ }) : itemsRef.current;
353
351
  const newIndex = newSortedItems.findIndex((item) => item === currentSelectedItem);
354
352
  if (newIndex !== -1) {
355
353
  selectedIndexRef.current = newIndex;
@@ -487,8 +485,7 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
487
485
  }
488
486
  var import_react2, import_ink2, h2;
489
487
  var init_list_components = __esm({
490
- "src/screen/list-components.ts"() {
491
- "use strict";
488
+ "src/screen/list-components.js"() {
492
489
  import_react2 = __toESM(require("react"), 1);
493
490
  import_ink2 = require("ink");
494
491
  init_components();
@@ -496,7 +493,7 @@ var init_list_components = __esm({
496
493
  }
497
494
  });
498
495
 
499
- // src/screen/screens.ts
496
+ // src/screen/screens.js
500
497
  function groupKeyBindings(bindings) {
501
498
  const groups = {};
502
499
  const enabledBindings = bindings.filter((b) => b.enabled !== false);
@@ -572,7 +569,7 @@ async function showScreen(config2) {
572
569
  let renderResult = null;
573
570
  let initialized = false;
574
571
  const Screen = () => {
575
- const [updateCounter, setUpdateCounter] = (0, import_react3.useState)(0);
572
+ const [, setUpdateCounter] = (0, import_react3.useState)(0);
576
573
  if (!initialized) {
577
574
  const defaultBindings = [
578
575
  { key: "escape", caption: "go back", action: "back", protected: true, order: 1 },
@@ -691,7 +688,7 @@ async function showScreen(config2) {
691
688
  }
692
689
  }
693
690
  if (matchedBinding && actions[matchedBinding.action]) {
694
- const actionResult = actions[matchedBinding.action]({
691
+ actions[matchedBinding.action]({
695
692
  input,
696
693
  key,
697
694
  binding: matchedBinding
@@ -821,8 +818,7 @@ async function showMultiColumnListWithPreviewScreen(config2) {
821
818
  }
822
819
  var import_react3, import_ink3, showMenuScreen, showWordGridScreen;
823
820
  var init_screens = __esm({
824
- "src/screen/screens.ts"() {
825
- "use strict";
821
+ "src/screen/screens.js"() {
826
822
  import_react3 = require("react");
827
823
  import_ink3 = require("ink");
828
824
  init_components();
@@ -832,7 +828,7 @@ var init_screens = __esm({
832
828
  }
833
829
  });
834
830
 
835
- // src/screen/ui-elements.ts
831
+ // src/screen/ui-elements.js
836
832
  function ListItem({
837
833
  children,
838
834
  isSelected = false,
@@ -857,7 +853,7 @@ function TextBlock({
857
853
  color = "white",
858
854
  dimmed = false,
859
855
  bold = false,
860
- maxWidth
856
+ maxWidth: _maxWidth
861
857
  }) {
862
858
  return (0, import_react4.createElement)(
863
859
  import_ink4.Box,
@@ -897,7 +893,7 @@ function GridCell({
897
893
  }, children)
898
894
  );
899
895
  }
900
- function InputField({ prompt, value, onChange, onSubmit }) {
896
+ function InputField({ prompt, value, onChange: _onChange, onSubmit: _onSubmit }) {
901
897
  return (0, import_react4.createElement)(
902
898
  import_ink4.Box,
903
899
  { flexDirection: "column" },
@@ -911,14 +907,13 @@ function InputField({ prompt, value, onChange, onSubmit }) {
911
907
  }
912
908
  var import_react4, import_ink4;
913
909
  var init_ui_elements = __esm({
914
- "src/screen/ui-elements.ts"() {
915
- "use strict";
910
+ "src/screen/ui-elements.js"() {
916
911
  import_react4 = require("react");
917
912
  import_ink4 = require("ink");
918
913
  }
919
914
  });
920
915
 
921
- // src/screen/utils.ts
916
+ // src/screen/utils.js
922
917
  function buildBreadcrumb(parts) {
923
918
  if (parts.length === 0) return "";
924
919
  if (parts.length === 1) return parts[0];
@@ -932,12 +927,11 @@ function buildDetailBreadcrumb(path6, suffix = "") {
932
927
  return suffix ? `${breadcrumb} ${suffix}` : breadcrumb;
933
928
  }
934
929
  var init_utils = __esm({
935
- "src/screen/utils.ts"() {
936
- "use strict";
930
+ "src/screen/utils.js"() {
937
931
  }
938
932
  });
939
933
 
940
- // src/screen/footer-builder.ts
934
+ // src/screen/footer-builder.js
941
935
  function buildFooter(config2 = {}) {
942
936
  const {
943
937
  navigation = null,
@@ -988,8 +982,7 @@ function organizeFooterMessages(messages) {
988
982
  }
989
983
  var FooterPresets;
990
984
  var init_footer_builder = __esm({
991
- "src/screen/footer-builder.ts"() {
992
- "use strict";
985
+ "src/screen/footer-builder.js"() {
993
986
  FooterPresets = {
994
987
  /**
995
988
  * Menu screen footer
@@ -1048,7 +1041,7 @@ var init_footer_builder = __esm({
1048
1041
  }
1049
1042
  });
1050
1043
 
1051
- // src/screen/index.ts
1044
+ // src/screen/index.js
1052
1045
  var screen_exports = {};
1053
1046
  __export(screen_exports, {
1054
1047
  Box: () => import_ink5.Box,
@@ -1074,6 +1067,7 @@ __export(screen_exports, {
1074
1067
  buildFooter: () => buildFooter,
1075
1068
  h: () => import_react5.createElement,
1076
1069
  load: () => load,
1070
+ memo: () => import_react5.memo,
1077
1071
  organizeFooterMessages: () => organizeFooterMessages,
1078
1072
  showListScreen: () => showListScreen,
1079
1073
  showMenuScreen: () => showMenuScreen,
@@ -1084,6 +1078,7 @@ __export(screen_exports, {
1084
1078
  useCallback: () => import_react5.useCallback,
1085
1079
  useEffect: () => import_react5.useEffect,
1086
1080
  useInput: () => import_ink5.useInput,
1081
+ useLayoutEffect: () => import_react5.useLayoutEffect,
1087
1082
  useMemo: () => import_react5.useMemo,
1088
1083
  useRef: () => import_react5.useRef,
1089
1084
  useState: () => import_react5.useState
@@ -1099,8 +1094,7 @@ async function load() {
1099
1094
  }
1100
1095
  var import_react5, import_ink5, loadPromise;
1101
1096
  var init_screen = __esm({
1102
- "src/screen/index.ts"() {
1103
- "use strict";
1097
+ "src/screen/index.js"() {
1104
1098
  import_react5 = __toESM(require("react"), 1);
1105
1099
  import_ink5 = require("ink");
1106
1100
  init_screens();
@@ -1117,11 +1111,11 @@ var init_screen = __esm({
1117
1111
  }
1118
1112
  });
1119
1113
 
1120
- // src/scripts/cli-runner.ts
1114
+ // src/scripts/cli-runner.js
1121
1115
  var import_node_path2 = __toESM(require("path"), 1);
1122
1116
  var import_node_url = require("url");
1123
1117
 
1124
- // src/args/index.ts
1118
+ // src/args/index.js
1125
1119
  var import_fs = require("fs");
1126
1120
  var import_path = require("path");
1127
1121
  var import_dotenv = require("dotenv");
@@ -1604,10 +1598,10 @@ var Args = class _Args {
1604
1598
  }
1605
1599
  };
1606
1600
 
1607
- // src/params/index.ts
1601
+ // src/params/index.js
1608
1602
  var import_joi = __toESM(require("joi"), 1);
1609
1603
 
1610
- // src/errors.ts
1604
+ // src/errors.js
1611
1605
  var FrameworkError = class extends Error {
1612
1606
  constructor(message) {
1613
1607
  super(message);
@@ -1633,7 +1627,7 @@ var FileDatabaseError = class extends FrameworkError {
1633
1627
  }
1634
1628
  };
1635
1629
 
1636
- // src/params/custom-types.ts
1630
+ // src/params/custom-types.js
1637
1631
  var joiEdateType = (value, helpers) => {
1638
1632
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(value)) {
1639
1633
  const testDate = new Date(value);
@@ -1726,7 +1720,7 @@ function calculateTimeOffset(amount, unit, sign) {
1726
1720
  }
1727
1721
  return sign === "+" ? amount * multiplier : -amount * multiplier;
1728
1722
  }
1729
- var joiStringArrayType = (type) => (value, helpers) => {
1723
+ var joiStringArrayType = (type) => (value, _helpers) => {
1730
1724
  if (value === void 0 || typeof value === "function") {
1731
1725
  return [];
1732
1726
  }
@@ -1752,7 +1746,7 @@ var joiStringArrayType = (type) => (value, helpers) => {
1752
1746
  return arr;
1753
1747
  };
1754
1748
 
1755
- // src/params/index.ts
1749
+ // src/params/index.js
1756
1750
  var Params = class _Params {
1757
1751
  context;
1758
1752
  // Partial context during initialization
@@ -2012,7 +2006,7 @@ var Params = class _Params {
2012
2006
  definition = val;
2013
2007
  val = val.value;
2014
2008
  }
2015
- const def = this.assignDefinition(key, definition);
2009
+ this.assignDefinition(key, definition);
2016
2010
  if (!this.runAllRegisteredSetters(key, val)) {
2017
2011
  this.params[key] = val;
2018
2012
  }
@@ -2131,11 +2125,11 @@ var Params = class _Params {
2131
2125
  }
2132
2126
  };
2133
2127
 
2134
- // src/logger/index.ts
2128
+ // src/logger/index.js
2135
2129
  var import_chalk = __toESM(require("chalk"), 1);
2136
2130
  var import_util = __toESM(require("util"), 1);
2137
2131
 
2138
- // src/logger/transports.ts
2132
+ // src/logger/transports.js
2139
2133
  var ConsoleTransport = class {
2140
2134
  write(payload) {
2141
2135
  console.info(payload);
@@ -2155,7 +2149,7 @@ var ParentProcessTransport = class {
2155
2149
  }
2156
2150
  };
2157
2151
 
2158
- // src/logger/index.ts
2152
+ // src/logger/index.js
2159
2153
  var ALL_LEVELS = [
2160
2154
  "silly",
2161
2155
  "debug",
@@ -2228,6 +2222,7 @@ var Logger = class _Logger {
2228
2222
  }
2229
2223
  /**
2230
2224
  * Initialize logger from context and CLI parameters. Whatever is in options goes (after discovered params).
2225
+ * Params are tracked under the `logger` module for --showUsedParams.
2231
2226
  */
2232
2227
  static init(context, options) {
2233
2228
  const paramDefs = {
@@ -2241,7 +2236,7 @@ var Logger = class _Logger {
2241
2236
  progressWithTimes: "boolean default false",
2242
2237
  progressThrottleMs: "number"
2243
2238
  };
2244
- const discovered = context.params.getAllForModule(paramDefs);
2239
+ const discovered = context.params.getAllForModule("logger", paramDefs);
2245
2240
  const config2 = { ...discovered, ...options };
2246
2241
  const logger = new _Logger(context, config2);
2247
2242
  context.logger = logger;
@@ -2437,9 +2432,9 @@ var Logger = class _Logger {
2437
2432
  }
2438
2433
  };
2439
2434
 
2440
- // src/init/index.ts
2435
+ // src/init/index.js
2441
2436
  var import_events = require("events");
2442
- function extractComponentOptions(opts, componentName) {
2437
+ function extractComponentOptions(opts, _componentName) {
2443
2438
  const reservedKeys = ["overrides", "defaults", "modules"];
2444
2439
  const componentOptions = {};
2445
2440
  for (const [key, value] of Object.entries(opts)) {
@@ -2506,6 +2501,19 @@ function printAllParameters(context) {
2506
2501
  async function init(flow2, opts = {}) {
2507
2502
  let stop = false;
2508
2503
  let context = null;
2504
+ let cleanupRan = false;
2505
+ const runRegisteredCleanups = async (ctx) => {
2506
+ if (cleanupRan) return;
2507
+ cleanupRan = true;
2508
+ const fns = [...ctx.cleanupFunctions].reverse();
2509
+ for (const fn of fns) {
2510
+ try {
2511
+ await fn(ctx);
2512
+ } catch (error) {
2513
+ ctx.logger.warn("[cleanup] error in cleanup function:", error);
2514
+ }
2515
+ }
2516
+ };
2509
2517
  try {
2510
2518
  try {
2511
2519
  const screenModule = await Promise.resolve().then(() => (init_screen(), screen_exports));
@@ -2528,13 +2536,24 @@ async function init(flow2, opts = {}) {
2528
2536
  printAllParameters(context);
2529
2537
  process.exit(0);
2530
2538
  }
2539
+ let sigintCount = 0;
2531
2540
  process.on("SIGINT", async () => {
2532
- if (stop) {
2533
- context.logger.warn("[process] killed");
2534
- process.exit(2);
2541
+ if (!context) return;
2542
+ sigintCount += 1;
2543
+ if (sigintCount === 1) {
2544
+ stop = true;
2545
+ context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2546
+ context.emitter.emit("stop", stopAllowance);
2547
+ return;
2535
2548
  }
2549
+ context.logger.warn("[process] second SIGINT: running cleanup then exit");
2550
+ await runRegisteredCleanups(context);
2551
+ process.exit(2);
2552
+ });
2553
+ process.on("SIGTERM", () => {
2554
+ if (!context || stop) return;
2536
2555
  stop = true;
2537
- context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2556
+ context.logger.info(`>> SIGTERM: emitting stop with allowance ${stopAllowance}`);
2538
2557
  context.emitter.emit("stop", stopAllowance);
2539
2558
  });
2540
2559
  await flow2(context);
@@ -2559,33 +2578,62 @@ async function init(flow2, opts = {}) {
2559
2578
  }
2560
2579
  } finally {
2561
2580
  if (context) {
2562
- for (const fn of context.cleanupFunctions.reverse()) {
2563
- try {
2564
- await fn(context);
2565
- } catch (error) {
2566
- context.logger.warn("[cleanup] error in cleanup function:", error);
2567
- }
2568
- }
2581
+ await runRegisteredCleanups(context);
2569
2582
  }
2570
2583
  }
2571
2584
  }
2572
2585
 
2573
- // src/db/index.ts
2586
+ // src/db/index.js
2574
2587
  var import_knex = __toESM(require("knex"), 1);
2588
+ var KNEX_DEFAULTS = {
2589
+ testConnection: true,
2590
+ pool: { min: 2, max: 10 },
2591
+ acquireConnectionTimeout: 1e4,
2592
+ ssl: { rejectUnauthorized: false }
2593
+ };
2575
2594
  var Db = class {
2576
- knexInstance = null;
2577
- config;
2578
- logger;
2579
- queriesLog = [];
2580
- isConnected = false;
2581
- /**
2582
- * Constructor - accepts config object
2583
- * Use dbInit() function to initialize with Context
2584
- */
2595
+ static async init(context, options = {}) {
2596
+ const defs2 = {
2597
+ dbName: "string",
2598
+ dbConnectionString: "string",
2599
+ dbProfile: "boolean default false"
2600
+ };
2601
+ const discovered = context?.params?.getAllForModule?.("db", defs2) ?? {};
2602
+ const merged = { ...discovered, ...options };
2603
+ let { dbName, dbConnectionString } = merged;
2604
+ const { dbProfile } = merged;
2605
+ if (!dbName && !dbConnectionString) {
2606
+ dbName = "local";
2607
+ }
2608
+ if (dbName && /^(postgresql|mysql):\/\//.test(dbName)) {
2609
+ dbConnectionString = dbName;
2610
+ dbName = void 0;
2611
+ }
2612
+ if (dbName && !dbConnectionString) {
2613
+ const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2614
+ dbConnectionString = await context.params.get(paramName, "string");
2615
+ if (!dbConnectionString) {
2616
+ throw new ParamError(
2617
+ `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2618
+ );
2619
+ }
2620
+ }
2621
+ const config2 = {
2622
+ ...KNEX_DEFAULTS,
2623
+ connectionString: dbConnectionString,
2624
+ name: dbName || merged.name || "default",
2625
+ profile: !!dbProfile,
2626
+ logger: context.logger
2627
+ };
2628
+ return dbConnect(context, config2);
2629
+ }
2585
2630
  constructor(config2) {
2586
- if (!config2.connectionString) {
2631
+ if (!config2 || !config2.connectionString) {
2587
2632
  throw new ParamError("Db: connectionString is required");
2588
2633
  }
2634
+ this.knexInstance = null;
2635
+ this.isConnected = false;
2636
+ this.queriesLog = [];
2589
2637
  this.config = {
2590
2638
  testConnection: true,
2591
2639
  profile: false,
@@ -2598,25 +2646,23 @@ var Db = class {
2598
2646
  };
2599
2647
  this.logger = this.config.logger;
2600
2648
  const instance = this;
2601
- const callableWrapper = function(...args) {
2649
+ const callableWrapper = function() {
2602
2650
  throw new Error("This should never be called directly");
2603
2651
  };
2604
2652
  callableWrapper._instance = instance;
2605
2653
  return new Proxy(callableWrapper, {
2606
- // Intercept function calls: db('table')
2607
- apply: (target, thisArg, argumentsList) => {
2654
+ apply: (target, _thisArg, argumentsList) => {
2608
2655
  const inst = target._instance;
2609
2656
  if (!inst.knexInstance) {
2610
2657
  throw new Error("Db: Not connected. Call connect() first.");
2611
2658
  }
2612
2659
  return inst.knexInstance(...argumentsList);
2613
2660
  },
2614
- // Intercept property access: db.schema, db.raw, etc.
2615
2661
  get: (target, prop) => {
2616
2662
  if (prop === "_instance") {
2617
2663
  return target._instance;
2618
2664
  }
2619
- const instance2 = target._instance;
2665
+ const inst = target._instance;
2620
2666
  const ownMethods = [
2621
2667
  "connect",
2622
2668
  "disconnect",
@@ -2629,26 +2675,26 @@ var Db = class {
2629
2675
  "detectClient",
2630
2676
  "attachProfiler"
2631
2677
  ];
2632
- if (prop in instance2) {
2633
- const value = instance2[prop];
2678
+ if (prop in inst) {
2679
+ const value = inst[prop];
2634
2680
  if (typeof value === "function" && ownMethods.includes(prop)) {
2635
- return value.bind(instance2);
2681
+ return value.bind(inst);
2636
2682
  }
2637
2683
  if (typeof value !== "function") {
2638
2684
  return value;
2639
2685
  }
2640
2686
  }
2641
- if (instance2.knexInstance) {
2642
- const knexProp = instance2.knexInstance[prop];
2687
+ if (inst.knexInstance) {
2688
+ const knexProp = inst.knexInstance[prop];
2643
2689
  if (typeof knexProp === "function") {
2644
- return knexProp.bind(instance2.knexInstance);
2690
+ return knexProp.bind(inst.knexInstance);
2645
2691
  }
2646
2692
  return knexProp;
2647
2693
  }
2648
- if (prop in instance2) {
2649
- const method = instance2[prop];
2694
+ if (prop in inst) {
2695
+ const method = inst[prop];
2650
2696
  if (typeof method === "function") {
2651
- return method.bind(instance2);
2697
+ return method.bind(inst);
2652
2698
  }
2653
2699
  return method;
2654
2700
  }
@@ -2656,9 +2702,6 @@ var Db = class {
2656
2702
  }
2657
2703
  });
2658
2704
  }
2659
- /**
2660
- * Detect database client type from connection string
2661
- */
2662
2705
  detectClient(connectionString) {
2663
2706
  if (connectionString.match(/^postgresql/)) {
2664
2707
  return "pg";
@@ -2668,9 +2711,6 @@ var Db = class {
2668
2711
  }
2669
2712
  return null;
2670
2713
  }
2671
- /**
2672
- * Connect to the database
2673
- */
2674
2714
  async connect() {
2675
2715
  if (this.isConnected && this.knexInstance) {
2676
2716
  this.logger.warn?.("[Db] Already connected");
@@ -2679,14 +2719,13 @@ var Db = class {
2679
2719
  const client = this.detectClient(this.config.connectionString);
2680
2720
  if (!client) {
2681
2721
  throw new ParamError(
2682
- `Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://`
2722
+ "Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://"
2683
2723
  );
2684
2724
  }
2685
2725
  try {
2686
2726
  const connectionConfig = {
2687
2727
  connectionString: this.config.connectionString,
2688
2728
  family: 4
2689
- // Force IPv4 only (disable IPv6)
2690
2729
  };
2691
2730
  this.knexInstance = (0, import_knex.default)({
2692
2731
  client,
@@ -2702,7 +2741,9 @@ var Db = class {
2702
2741
  await this.testConnection();
2703
2742
  }
2704
2743
  this.isConnected = true;
2705
- this.logger.debug?.(`[Db] Connected to database "${this.config.name || this.config.connectionString}"`);
2744
+ this.logger.debug?.(
2745
+ `[Db] Connected to database "${this.config.name || this.config.connectionString}"`
2746
+ );
2706
2747
  } catch (error) {
2707
2748
  if (error instanceof ParamError) {
2708
2749
  throw error;
@@ -2711,9 +2752,6 @@ var Db = class {
2711
2752
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
2712
2753
  }
2713
2754
  }
2714
- /**
2715
- * Disconnect from the database
2716
- */
2717
2755
  async disconnect() {
2718
2756
  if (!this.knexInstance) {
2719
2757
  return;
@@ -2723,16 +2761,15 @@ var Db = class {
2723
2761
  this.knexInstance = null;
2724
2762
  this.isConnected = false;
2725
2763
  this.queriesLog = [];
2726
- this.logger.debug?.(`[Db] Disconnected from database "${this.config.name || this.config.connectionString}"`);
2764
+ this.logger.debug?.(
2765
+ `[Db] Disconnected from database "${this.config.name || this.config.connectionString}"`
2766
+ );
2727
2767
  } catch (error) {
2728
2768
  const errorMsg = this.getErrorMessage(error);
2729
2769
  this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
2730
2770
  throw error;
2731
2771
  }
2732
2772
  }
2733
- /**
2734
- * Extract error message from various error types
2735
- */
2736
2773
  getErrorMessage(error) {
2737
2774
  if (error instanceof AggregateError) {
2738
2775
  const errors = error.errors || [];
@@ -2757,9 +2794,11 @@ var Db = class {
2757
2794
  return `${code} (tried: ${addresses.join(", ")})`;
2758
2795
  }
2759
2796
  }
2760
- const uniqueMessages = [...new Set(errors.map((e) => {
2761
- return e instanceof Error ? e.message : String(e);
2762
- }))];
2797
+ const uniqueMessages = [
2798
+ ...new Set(
2799
+ errors.map((e) => e instanceof Error ? e.message : String(e))
2800
+ )
2801
+ ];
2763
2802
  if (uniqueMessages.length === 1) {
2764
2803
  return uniqueMessages[0];
2765
2804
  }
@@ -2768,28 +2807,25 @@ var Db = class {
2768
2807
  return error.message || "Multiple errors occurred";
2769
2808
  }
2770
2809
  if (error instanceof Error) {
2771
- const errorWithCode = error;
2772
- if (errorWithCode.code) {
2773
- return `${errorWithCode.code}: ${error.message || String(error)}`;
2810
+ const code = error.code;
2811
+ if (code) {
2812
+ return `${code}: ${error.message || String(error)}`;
2774
2813
  }
2775
2814
  return error.message || String(error);
2776
2815
  }
2777
2816
  if (typeof error === "string") {
2778
2817
  return error;
2779
2818
  }
2780
- if (error?.message) {
2819
+ if (error && typeof error === "object" && "message" in error) {
2781
2820
  const msg = String(error.message);
2782
- const errorWithCode = error;
2783
- if (errorWithCode.code) {
2784
- return `${errorWithCode.code}: ${msg}`;
2821
+ const code = error.code;
2822
+ if (code) {
2823
+ return `${code}: ${msg}`;
2785
2824
  }
2786
2825
  return msg;
2787
2826
  }
2788
2827
  return String(error) || "Unknown error";
2789
2828
  }
2790
- /**
2791
- * Test database connection
2792
- */
2793
2829
  async testConnection() {
2794
2830
  if (!this.knexInstance) {
2795
2831
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2805,9 +2841,6 @@ var Db = class {
2805
2841
  throw new ParamError(`Db: Connection test failed - ${errorMsg}`);
2806
2842
  }
2807
2843
  }
2808
- /**
2809
- * Attach query profiler to log all queries
2810
- */
2811
2844
  attachProfiler() {
2812
2845
  if (!this.knexInstance) {
2813
2846
  return;
@@ -2817,7 +2850,7 @@ var Db = class {
2817
2850
  this.knexInstance.on("query", (query) => {
2818
2851
  query.__startTime = process.hrtime();
2819
2852
  });
2820
- this.knexInstance.on("query-response", (response, query) => {
2853
+ this.knexInstance.on("query-response", (_response, query) => {
2821
2854
  const [seconds, nanoseconds] = process.hrtime(query.__startTime);
2822
2855
  const executionTimeMs = (seconds * 1e3 + nanoseconds / 1e6).toFixed(2);
2823
2856
  const logEntry = {
@@ -2832,15 +2865,9 @@ var Db = class {
2832
2865
  this.logger.error?.(`[Db] Query failed: ${query.sql}`, error);
2833
2866
  });
2834
2867
  }
2835
- /**
2836
- * Get query log (only available if profiling is enabled)
2837
- */
2838
2868
  getQueryLog() {
2839
2869
  return [...this.queriesLog];
2840
2870
  }
2841
- /**
2842
- * Check if a table exists
2843
- */
2844
2871
  async tableExists(tableName) {
2845
2872
  if (!this.knexInstance) {
2846
2873
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2852,65 +2879,28 @@ var Db = class {
2852
2879
  throw error;
2853
2880
  }
2854
2881
  }
2855
- /**
2856
- * Get the underlying Knex instance (for advanced usage)
2857
- */
2858
2882
  getKnex() {
2859
2883
  if (!this.knexInstance) {
2860
2884
  throw new Error("Db: Not connected. Call connect() first.");
2861
2885
  }
2862
2886
  return this.knexInstance;
2863
2887
  }
2864
- /**
2865
- * Get connection status
2866
- */
2867
2888
  isConnectedToDb() {
2868
2889
  return this.isConnected && this.knexInstance !== null;
2869
2890
  }
2870
- /**
2871
- * Initialize Db with context (connects and registers disconnect cleanup).
2872
- * Params are read via getAllForModule("db", defs). Same as dbInit(context, dbNameOrConnectionString).
2873
- */
2874
- static async init(context, dbNameOrConnectionString) {
2875
- return dbFindAndConnect(context, dbNameOrConnectionString);
2876
- }
2877
2891
  };
2878
2892
  function capitalizeFirstLetter(str) {
2879
2893
  return str.charAt(0).toUpperCase() + str.slice(1);
2880
2894
  }
2881
- async function dbConnect(context, connectionString, name, dbProfile) {
2882
- const defs2 = {
2883
- testDbConnection: "boolean default true",
2884
- name: "string",
2885
- poolMin: "number default 2",
2886
- poolMax: "number default 10",
2887
- acquireConnectionTimeout: "number default 10000",
2888
- sslRejectUnauthorized: "boolean default false"
2889
- };
2890
- const paramsConfig = context.params.getAllForModule(defs2);
2891
- const config2 = {
2892
- connectionString,
2893
- name: paramsConfig.name || name || "default",
2894
- testConnection: paramsConfig.testDbConnection,
2895
- profile: dbProfile ?? false,
2896
- pool: {
2897
- min: paramsConfig.poolMin,
2898
- max: paramsConfig.poolMax
2899
- },
2900
- acquireConnectionTimeout: paramsConfig.acquireConnectionTimeout,
2901
- ssl: {
2902
- rejectUnauthorized: paramsConfig.sslRejectUnauthorized
2903
- },
2904
- logger: context.logger
2905
- };
2895
+ async function dbConnect(context, config2) {
2906
2896
  try {
2907
2897
  const db = new Db(config2);
2908
2898
  context.registerCleanup(async () => {
2909
2899
  await db.disconnect();
2910
- context.logger.debug(`[Db] instance "${name || connectionString}" destroyed`);
2900
+ context.logger.debug?.(`[Db] instance "${config2.name}" disconnected`);
2911
2901
  });
2912
2902
  await db.connect();
2913
- context.logger.debug(`[Db] instance "${name || connectionString}" initialized`);
2903
+ context.logger.debug?.(`[Db] instance "${config2.name}" initialized`);
2914
2904
  return db;
2915
2905
  } catch (error) {
2916
2906
  if (error instanceof ParamError) {
@@ -2920,48 +2910,11 @@ async function dbConnect(context, connectionString, name, dbProfile) {
2920
2910
  throw new ParamError(`[Db] connect error: ${errorMsg}`);
2921
2911
  }
2922
2912
  }
2923
- async function dbFindAndConnect(context, dbNameOrConnectionString) {
2924
- let dbName;
2925
- let dbConnectionString;
2926
- let dbProfile;
2927
- if (dbNameOrConnectionString) {
2928
- if (dbNameOrConnectionString.match(/^(postgresql|mysql):\/\/[^\s]+:[^\s]+@[^\s]+:\d+\/[^\s]+$/)) {
2929
- dbName = void 0;
2930
- dbConnectionString = dbNameOrConnectionString;
2931
- } else {
2932
- dbName = dbNameOrConnectionString;
2933
- }
2934
- } else {
2935
- const defs2 = {
2936
- dbName: "string",
2937
- dbConnectionString: "string",
2938
- dbProfile: "boolean default false"
2939
- };
2940
- const paramsConfig = context.params.getAll(defs2);
2941
- dbName = paramsConfig.dbName;
2942
- dbConnectionString = paramsConfig.dbConnectionString;
2943
- dbProfile = paramsConfig.dbProfile;
2944
- }
2945
- if (!dbName && !dbConnectionString) {
2946
- throw new ParamError("Db: either dbName or dbConnectionString must be specified");
2947
- }
2948
- if (dbName) {
2949
- const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2950
- dbConnectionString = await context.params.get(paramName, "string");
2951
- if (!dbConnectionString) {
2952
- throw new ParamError(
2953
- `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2954
- );
2955
- }
2956
- }
2957
- const db = await dbConnect(context, dbConnectionString, dbName, dbProfile);
2958
- return db;
2959
- }
2960
- async function dbInit(context, dbNameOrConnectionString) {
2961
- return await dbFindAndConnect(context, dbNameOrConnectionString);
2962
- }
2963
2913
 
2964
- // src/utils/date-utils.ts
2914
+ // src/tasks/index.js
2915
+ var import_node_os3 = __toESM(require("os"), 1);
2916
+
2917
+ // src/utils/date-utils.js
2965
2918
  function isTimestampFolder(folderName) {
2966
2919
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|\.\d{3}Z)$/;
2967
2920
  if (!isoRegex.test(folderName)) {
@@ -2971,7 +2924,7 @@ function isTimestampFolder(folderName) {
2971
2924
  return !isNaN(date.getTime()) && date.getTime() > 0;
2972
2925
  }
2973
2926
 
2974
- // src/utils/fs-utils.ts
2927
+ // src/utils/fs-utils.js
2975
2928
  var import_fs2 = __toESM(require("fs"), 1);
2976
2929
  var import_path2 = __toESM(require("path"), 1);
2977
2930
  async function ensurePath(...pathParts) {
@@ -2995,7 +2948,7 @@ function getFileExtension(dataType) {
2995
2948
  }
2996
2949
  }
2997
2950
 
2998
- // src/utils/os-utils.ts
2951
+ // src/utils/os-utils.js
2999
2952
  var import_fs3 = __toESM(require("fs"), 1);
3000
2953
  var import_path3 = __toESM(require("path"), 1);
3001
2954
  var import_child_process = require("child_process");
@@ -3024,7 +2977,7 @@ function getFreeDiskSpace(targetPath) {
3024
2977
  }
3025
2978
  }
3026
2979
 
3027
- // src/utils/format-utils.ts
2980
+ // src/utils/format-utils.js
3028
2981
  function bytesToHumanReadable(bytes) {
3029
2982
  if (bytes === 0) return "0 B";
3030
2983
  const k = 1024;
@@ -3033,7 +2986,7 @@ function bytesToHumanReadable(bytes) {
3033
2986
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
3034
2987
  }
3035
2988
 
3036
- // src/utils/core-utils.ts
2989
+ // src/utils/core-utils.js
3037
2990
  function sleepMs(ms) {
3038
2991
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3039
2992
  }
@@ -3042,14 +2995,82 @@ function toJsonColumn(value) {
3042
2995
  return JSON.stringify(value);
3043
2996
  }
3044
2997
 
3045
- // src/tasks/servicesRegistry.ts
3046
- var import_node_crypto2 = require("crypto");
3047
- var import_promises = require("fs/promises");
2998
+ // src/tasks/servicesRegistry.js
3048
2999
  var import_node_os = __toESM(require("os"), 1);
3049
- var import_node_path = __toESM(require("path"), 1);
3050
3000
 
3051
- // src/tasks/taskUtils.ts
3001
+ // src/tasks/taskUtils.js
3052
3002
  var import_node_crypto = require("crypto");
3003
+
3004
+ // src/tasks/time-matcher.js
3005
+ var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
3006
+ function resolveAsterisks(field, range) {
3007
+ return field.includes("*") ? field.replace("*", range) : field;
3008
+ }
3009
+ function resolveRanges(field) {
3010
+ const regex = /(\d+)-(\d+)/;
3011
+ let current = field;
3012
+ while (true) {
3013
+ const match = regex.exec(current);
3014
+ if (!match) break;
3015
+ const raw = match[0];
3016
+ let first = Number(match[1]);
3017
+ let last = Number(match[2]);
3018
+ if (last < first) {
3019
+ [first, last] = [last, first];
3020
+ }
3021
+ const values = [];
3022
+ for (let i = first; i <= last; i += 1) {
3023
+ values.push(i);
3024
+ }
3025
+ current = current.replace(raw, values.join(","));
3026
+ }
3027
+ return current;
3028
+ }
3029
+ function resolveSteps(field) {
3030
+ const match = /^(.+)\/(\d+)$/.exec(field);
3031
+ if (!match) return field;
3032
+ const base = match[1];
3033
+ const step = Number(match[2]);
3034
+ if (!Number.isFinite(step) || step <= 0) return field;
3035
+ return base.split(",").map((v) => Number(v)).filter((v) => Number.isFinite(v) && v % step === 0).join(",");
3036
+ }
3037
+ function convertPattern(pattern) {
3038
+ const parts = pattern.trim().split(/\s+/);
3039
+ if (parts.length !== 6) {
3040
+ throw new Error(`Invalid schedule "${pattern}". Expected 6 fields: sec min hour day month weekday`);
3041
+ }
3042
+ return parts.map((field, idx) => resolveAsterisks(field, RANGES[idx])).map((field) => resolveRanges(field)).map((field) => resolveSteps(field));
3043
+ }
3044
+ function fieldMatches(field, value) {
3045
+ const allowed = field.split(",").map((v) => Number(v));
3046
+ return allowed.includes(value);
3047
+ }
3048
+ function matchesParsedPattern(parsed, date) {
3049
+ return fieldMatches(parsed[0], date.getSeconds()) && fieldMatches(parsed[1], date.getMinutes()) && fieldMatches(parsed[2], date.getHours()) && fieldMatches(parsed[3], date.getDate()) && fieldMatches(parsed[4], date.getMonth() + 1) && fieldMatches(parsed[5], date.getDay());
3050
+ }
3051
+ function timeMatcher(pattern, date = /* @__PURE__ */ new Date()) {
3052
+ const parsed = convertPattern(pattern);
3053
+ return matchesParsedPattern(parsed, date);
3054
+ }
3055
+ var MS_PER_SECOND = 1e3;
3056
+ var DEFAULT_SEARCH_HORIZON_MS = 10 * 365 * 24 * 60 * 60 * MS_PER_SECOND;
3057
+ function nextTimeMatch(pattern, from = /* @__PURE__ */ new Date(), maxSearchMs = DEFAULT_SEARCH_HORIZON_MS) {
3058
+ const parsed = convertPattern(pattern);
3059
+ let t = Math.ceil((from.getTime() + 1) / MS_PER_SECOND) * MS_PER_SECOND;
3060
+ const end = t + maxSearchMs;
3061
+ while (t <= end) {
3062
+ const date = new Date(t);
3063
+ if (matchesParsedPattern(parsed, date)) {
3064
+ return date;
3065
+ }
3066
+ t += MS_PER_SECOND;
3067
+ }
3068
+ throw new Error(
3069
+ `nextTimeMatch: no match for "${pattern}" within ${maxSearchMs}ms after ${from.toISOString()}`
3070
+ );
3071
+ }
3072
+
3073
+ // src/tasks/taskUtils.js
3053
3074
  function getDb(context) {
3054
3075
  const db = context.db;
3055
3076
  if (!db) {
@@ -3057,97 +3078,84 @@ function getDb(context) {
3057
3078
  }
3058
3079
  return db;
3059
3080
  }
3060
- function queueToTableNames(queue) {
3061
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
3062
- throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
3063
- }
3081
+ function queueToTableNames(queueName) {
3064
3082
  return {
3065
- tasksTable: queue,
3066
- historyTable: `${queue}_history`
3083
+ tasksTable: queueName,
3084
+ historyTable: `${queueName}_history`,
3085
+ registryTable: `${queueName}_services_registry`
3067
3086
  };
3068
3087
  }
3069
- function servicesRegistryTable(queue) {
3070
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
3071
- throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
3072
- }
3073
- return `${queue}_services_registry`;
3088
+ function defineTasksTable(t, db, tableNameForIndex) {
3089
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3090
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3091
+ t.timestamp("started_at");
3092
+ t.timestamp("completed_at");
3093
+ t.integer("priority").notNullable().defaultTo(50);
3094
+ t.text("schedule");
3095
+ t.timestamp("next_run_at").defaultTo(null);
3096
+ t.timestamp("past_due").defaultTo(null);
3097
+ t.text("name").notNullable();
3098
+ t.text("opid");
3099
+ t.json("params");
3100
+ t.text("service_group");
3101
+ t.integer("instance_number");
3102
+ t.text("service_name");
3103
+ t.text("server_name");
3104
+ t.text("status").notNullable().defaultTo("idle");
3105
+ t.timestamp("status_changed_at").defaultTo(null);
3106
+ t.text("progress");
3107
+ t.boolean("success");
3108
+ t.json("results");
3109
+ t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
3110
+ t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
3111
+ }
3112
+ function taskHistoryInsertFromQueueRow(row, overrides) {
3113
+ const { id, ...snapshot } = row;
3114
+ void id;
3115
+ return {
3116
+ ...snapshot,
3117
+ ...overrides
3118
+ };
3074
3119
  }
3075
3120
  async function ensureTaskTables(context, options = {}) {
3076
- const queue = options.queue ?? "tasks";
3121
+ const queueName = options.queueName ?? "tasks";
3077
3122
  const recreate = options.recreate ?? false;
3078
3123
  const db = getDb(context);
3079
- const { tasksTable, historyTable } = queueToTableNames(queue);
3124
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
3080
3125
  const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
3081
3126
  const needsHistory = recreate ? true : !await db.tableExists(historyTable);
3127
+ const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
3082
3128
  if (recreate) {
3083
3129
  await db.schema.dropTableIfExists(historyTable);
3084
3130
  await db.schema.dropTableIfExists(tasksTable);
3131
+ await db.schema.dropTableIfExists(registryTable);
3085
3132
  }
3086
3133
  if (needsTasks) {
3087
3134
  await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
3088
3135
  await db.schema.createTable(tasksTable, (t) => {
3089
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3090
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3091
- t.timestamp("started_at");
3092
- t.timestamp("completed_at");
3093
- t.integer("priority").notNullable().defaultTo(0);
3094
- t.text("schedule");
3095
- t.timestamp("past_due").defaultTo(null);
3096
- t.text("target").notNullable();
3097
- t.text("task").notNullable();
3098
- t.json("params");
3099
- t.text("opid");
3100
- t.timestamp("paused_at").defaultTo(null);
3101
- t.text("progress");
3102
- t.boolean("success");
3103
- t.json("results");
3104
- });
3105
- await db.schema.alterTable(tasksTable, (t) => {
3106
- t.index(["target", "started_at", "created_at"], `${tasksTable}_target_started_created_idx`);
3107
- t.index(["target", "past_due", "priority", "created_at"], `${tasksTable}_target_past_due_priority_created_idx`);
3108
- t.index(["target", "task"], `${tasksTable}_target_task_idx`);
3136
+ defineTasksTable(t, db, tasksTable);
3109
3137
  });
3110
3138
  }
3111
3139
  if (needsHistory) {
3112
3140
  await db.schema.createTable(historyTable, (t) => {
3113
- t.uuid("id").notNullable();
3114
- t.timestamp("created_at").notNullable();
3115
- t.timestamp("started_at");
3116
- t.timestamp("completed_at");
3117
- t.integer("priority").notNullable().defaultTo(0);
3118
- t.text("schedule");
3119
- t.timestamp("past_due").defaultTo(null);
3120
- t.text("target").notNullable();
3121
- t.text("task").notNullable();
3122
- t.json("params");
3123
- t.text("opid");
3124
- t.text("progress");
3125
- t.boolean("success");
3126
- t.json("results");
3127
- });
3128
- await db.schema.alterTable(historyTable, (t) => {
3129
- t.index(["target", "created_at"], `${historyTable}_target_created_idx`);
3130
- t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
3141
+ defineTasksTable(t, db, historyTable);
3131
3142
  });
3132
3143
  }
3133
- const registryTable = servicesRegistryTable(queue);
3134
- const needsRegistry = !await db.tableExists(registryTable);
3135
3144
  if (needsRegistry) {
3136
3145
  await db.schema.createTable(registryTable, (t) => {
3137
3146
  t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3138
- t.uuid("instance_id").notNullable().unique();
3139
- t.text("queue").notNullable();
3147
+ t.text("queue_name").notNullable();
3140
3148
  t.text("service_group").notNullable();
3149
+ t.integer("instance_number").notNullable().defaultTo(1);
3141
3150
  t.text("service_name").notNullable();
3142
- t.text("target").notNullable();
3143
- t.text("hostname");
3151
+ t.text("server_name").notNullable();
3144
3152
  t.integer("pid");
3145
3153
  t.json("metadata");
3146
3154
  t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3147
3155
  t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
3148
- t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
3149
- t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
3150
- t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
3156
+ t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
3157
+ t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
3158
+ t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
3151
3159
  });
3152
3160
  }
3153
3161
  }
@@ -3158,7 +3166,7 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
3158
3166
  });
3159
3167
  }
3160
3168
 
3161
- // src/tasks/servicesRegistry.ts
3169
+ // src/tasks/servicesRegistry.js
3162
3170
  function getDb2(context) {
3163
3171
  const db = context.db;
3164
3172
  if (!db) {
@@ -3169,6 +3177,7 @@ function getDb2(context) {
3169
3177
  var DEFAULT_GROUP_MAX_INSTANCES = {
3170
3178
  intake: 1,
3171
3179
  harvest: 1,
3180
+ harvester: 0,
3172
3181
  loader: 0,
3173
3182
  photos: 0,
3174
3183
  photosprocessor: 0,
@@ -3178,25 +3187,6 @@ function sanitizeNamePart(raw) {
3178
3187
  const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
3179
3188
  return s.slice(0, 80) || "runner";
3180
3189
  }
3181
- function identityFilePath(identityDir, queue, serviceGroup) {
3182
- const safeQ = sanitizeNamePart(queue);
3183
- const safeG = sanitizeNamePart(serviceGroup);
3184
- return import_node_path.default.join(identityDir, `${safeQ}_${safeG}.json`);
3185
- }
3186
- async function readIdentityFile(filePath) {
3187
- try {
3188
- const text = await (0, import_promises.readFile)(filePath, "utf8");
3189
- const parsed = JSON.parse(text);
3190
- return parsed && typeof parsed === "object" ? parsed : {};
3191
- } catch {
3192
- return {};
3193
- }
3194
- }
3195
- async function writeIdentityFile(filePath, data) {
3196
- await (0, import_promises.mkdir)(import_node_path.default.dirname(filePath), { recursive: true });
3197
- await (0, import_promises.writeFile)(filePath, `${JSON.stringify(data, null, 2)}
3198
- `, "utf8");
3199
- }
3200
3190
  function resolveMaxInstances(serviceGroup, override) {
3201
3191
  if (override !== void 0 && Number.isFinite(override)) {
3202
3192
  return Math.max(0, Math.floor(Number(override)));
@@ -3204,184 +3194,198 @@ function resolveMaxInstances(serviceGroup, override) {
3204
3194
  const g = serviceGroup.trim().toLowerCase();
3205
3195
  return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
3206
3196
  }
3207
- async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
3197
+ async function countAliveInGroup(db, registryTable, queueName, serviceGroup, staleMs, excludeRowId) {
3208
3198
  const cutoff = new Date(Date.now() - staleMs);
3209
- let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
3210
- if (excludeInstanceId) {
3211
- q = q.whereNot("instance_id", excludeInstanceId);
3199
+ let q = db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
3200
+ if (excludeRowId) {
3201
+ q = q.whereNot("id", excludeRowId);
3212
3202
  }
3213
3203
  const row = await q.count("id as count").first();
3214
3204
  return Number(row?.count ?? 0);
3215
3205
  }
3206
+ async function getOccupiedInstanceSlots(db, registryTable, queueName, serviceGroup, staleMs) {
3207
+ const cutoff = new Date(Date.now() - staleMs);
3208
+ const rows = await db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff).select("instance_number");
3209
+ const set = /* @__PURE__ */ new Set();
3210
+ for (const r of rows) {
3211
+ const n = Number(r.instance_number);
3212
+ if (Number.isFinite(n) && n >= 1) set.add(Math.floor(n));
3213
+ }
3214
+ return set;
3215
+ }
3216
3216
  function isUniqueViolation(error) {
3217
3217
  const code = error?.code ?? error?.errno;
3218
3218
  return code === "23505" || String(error?.message || "").includes("duplicate key");
3219
3219
  }
3220
+ function buildMetadata(options) {
3221
+ const base = options.metadata && typeof options.metadata === "object" ? { ...options.metadata } : {};
3222
+ if (options.target) {
3223
+ base.runnerTarget = options.target;
3224
+ }
3225
+ return toJsonColumn(Object.keys(base).length ? base : null);
3226
+ }
3227
+ function allocateInstanceNumber(occupied, explicit, maxSlots) {
3228
+ if (explicit !== void 0 && explicit !== null && Number.isFinite(Number(explicit))) {
3229
+ const e = Math.max(1, Math.floor(Number(explicit)));
3230
+ if (occupied.has(e)) {
3231
+ throw new Error(`[services-registry] instance slot ${e} is already occupied by an alive peer`);
3232
+ }
3233
+ if (maxSlots !== void 0 && maxSlots > 0 && e > maxSlots) {
3234
+ throw new Error(`[services-registry] instance ${e} exceeds configured max slots ${maxSlots} for this group`);
3235
+ }
3236
+ return e;
3237
+ }
3238
+ const cap = maxSlots !== void 0 && maxSlots > 0 ? maxSlots : 1e4;
3239
+ for (let n = 1; n <= cap; n++) {
3240
+ if (!occupied.has(n)) return n;
3241
+ }
3242
+ throw new Error(`[services-registry] no free instance slot (searched 1..${cap})`);
3243
+ }
3244
+ function defaultServiceName(groupBase, hostBase, instanceNumber) {
3245
+ return `${groupBase}-${hostBase}-${instanceNumber}`;
3246
+ }
3220
3247
  async function registerInServicesRegistry(context, options) {
3221
3248
  const db = getDb2(context);
3222
- const registryTable = servicesRegistryTable(options.queue);
3249
+ const registryTable = queueToTableNames(options.queueName).registryTable;
3223
3250
  const serviceGroup = options.serviceGroup.trim();
3224
3251
  if (!serviceGroup) {
3225
3252
  throw new Error("registerInServicesRegistry: serviceGroup is required");
3226
3253
  }
3227
- const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
3228
- let identity = await readIdentityFile(identityPath);
3229
- let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : (0, import_node_crypto2.randomUUID)();
3230
- identity.instanceId = instanceId;
3231
- await writeIdentityFile(identityPath, identity);
3232
- const hostname = import_node_os.default.hostname();
3254
+ const serverName = import_node_os.default.hostname();
3233
3255
  const pid = typeof process.pid === "number" ? process.pid : null;
3234
- const meta = toJsonColumn(options.metadata ?? null);
3235
- const existing = await db(registryTable).where({ instance_id: instanceId }).first();
3236
- if (existing) {
3237
- await db(registryTable).where({ instance_id: instanceId }).update({
3238
- target: options.target,
3239
- hostname,
3240
- pid,
3241
- metadata: meta,
3242
- last_seen_at: db.fn.now()
3243
- });
3244
- const serviceName = String(existing.service_name);
3245
- identity.serviceName = serviceName;
3246
- await writeIdentityFile(identityPath, identity);
3247
- const reg = {
3248
- instanceId,
3249
- serviceName,
3250
- serviceGroup,
3251
- queue: options.queue,
3252
- target: options.target,
3253
- rowId: String(existing.id)
3254
- };
3255
- context.servicesRegistry = reg;
3256
- context.runnerHeartbeat = reg;
3257
- context.logger.info?.(
3258
- `[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
3259
- );
3260
- return {
3261
- instanceId,
3262
- serviceName,
3263
- serviceGroup,
3264
- queue: options.queue,
3265
- target: options.target,
3266
- rowId: String(existing.id),
3267
- registryTable
3268
- };
3269
- }
3256
+ const meta = buildMetadata(options);
3257
+ const groupBase = sanitizeNamePart(serviceGroup);
3258
+ const hostBase = sanitizeNamePart(serverName);
3270
3259
  const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
3271
- const aliveOthers = await countAliveInGroup(
3272
- db,
3273
- registryTable,
3274
- options.queue,
3275
- serviceGroup,
3276
- options.staleMs,
3277
- instanceId
3278
- );
3279
- if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
3280
- const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
3260
+ const aliveCount = await countAliveInGroup(db, registryTable, options.queueName, serviceGroup, options.staleMs, void 0);
3261
+ if (maxAllowed > 0 && aliveCount >= maxAllowed) {
3262
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveCount} alive (max ${maxAllowed}, queue=${options.queueName}).`;
3281
3263
  if (options.enforceMaxInstances) {
3282
3264
  throw new Error(msg);
3283
3265
  }
3284
3266
  context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
3285
3267
  }
3286
- const explicitName = options.serviceName?.trim();
3287
- const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
3288
- const hostBase = sanitizeNamePart(hostname);
3289
- const groupBase = sanitizeNamePart(serviceGroup);
3290
- const baseCandidates = [];
3291
- if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
3292
- if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
3293
- baseCandidates.push(`${groupBase}-${hostBase}`);
3294
- baseCandidates.push(groupBase);
3295
- function* eachServiceNameCandidate(bases) {
3296
- const seen = /* @__PURE__ */ new Set();
3297
- for (const rawBase of bases) {
3298
- const base = sanitizeNamePart(rawBase);
3299
- if (!base) continue;
3300
- const seq = [base];
3301
- for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
3302
- for (const c of seq) {
3303
- if (seen.has(c)) continue;
3304
- seen.add(c);
3305
- yield c;
3268
+ const maxSlots = maxAllowed > 0 ? maxAllowed : void 0;
3269
+ const cutoff = new Date(Date.now() - options.staleMs);
3270
+ const MAX_ATTEMPTS = 8;
3271
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
3272
+ const occupied = await getOccupiedInstanceSlots(db, registryTable, options.queueName, serviceGroup, options.staleMs);
3273
+ const instanceNumber = allocateInstanceNumber(occupied, options.instanceNumber, maxSlots);
3274
+ const serviceNameRaw = options.serviceName?.trim() ? sanitizeNamePart(options.serviceName.trim()) : defaultServiceName(groupBase, hostBase, instanceNumber);
3275
+ const existing = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3276
+ if (existing) {
3277
+ const lastSeen = new Date(existing.last_seen_at);
3278
+ const isAlive = !Number.isNaN(lastSeen.getTime()) && lastSeen > cutoff;
3279
+ if (isAlive) {
3280
+ if (options.serviceName?.trim()) {
3281
+ throw new Error(
3282
+ `[services-registry] service_name "${serviceNameRaw}" is already registered by an alive peer`
3283
+ );
3284
+ }
3285
+ context.logger.warn?.(
3286
+ `[services-registry] service_name "${serviceNameRaw}" already alive; retrying allocation (attempt ${attempt + 1})`
3287
+ );
3288
+ if (options.instanceNumber !== void 0 && options.instanceNumber !== null) {
3289
+ throw new Error(
3290
+ `[services-registry] instance slot ${instanceNumber} / name "${serviceNameRaw}" is already held by an alive peer`
3291
+ );
3292
+ }
3293
+ await new Promise((r) => setTimeout(r, 50 + attempt * 30));
3294
+ continue;
3306
3295
  }
3296
+ await db(registryTable).where({ id: existing.id }).update({
3297
+ server_name: serverName,
3298
+ pid,
3299
+ metadata: meta,
3300
+ service_group: serviceGroup,
3301
+ instance_number: instanceNumber,
3302
+ last_seen_at: db.fn.now()
3303
+ });
3304
+ const reg = {
3305
+ serviceName: serviceNameRaw,
3306
+ serviceGroup,
3307
+ queueName: options.queueName,
3308
+ target: options.target,
3309
+ rowId: String(existing.id),
3310
+ registryTable,
3311
+ instanceNumber
3312
+ };
3313
+ context.servicesRegistry = reg;
3314
+ context.runnerHeartbeat = reg;
3315
+ context.logger.info?.(
3316
+ `[services-registry] took over stale row name=${serviceNameRaw} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3317
+ );
3318
+ return reg;
3307
3319
  }
3308
- }
3309
- let inserted;
3310
- for (const candidate of eachServiceNameCandidate(baseCandidates)) {
3311
3320
  try {
3312
3321
  const rows = await db(registryTable).insert({
3313
- instance_id: instanceId,
3314
- queue: options.queue,
3322
+ queue_name: options.queueName,
3315
3323
  service_group: serviceGroup,
3316
- service_name: candidate,
3317
- target: options.target,
3318
- hostname,
3324
+ instance_number: instanceNumber,
3325
+ service_name: serviceNameRaw,
3326
+ server_name: serverName,
3319
3327
  pid,
3320
3328
  metadata: meta,
3321
- last_seen_at: db.fn.now()
3329
+ last_seen_at: db.fn.now(),
3330
+ created_at: db.fn.now()
3322
3331
  }).returning(["id", "service_name"]);
3323
3332
  const row = Array.isArray(rows) ? rows[0] : rows;
3324
- if (row) {
3325
- inserted = { id: String(row.id), service_name: String(row.service_name) };
3326
- break;
3333
+ let rowId = row && typeof row === "object" ? String(row.id ?? "") : "";
3334
+ if (!rowId) {
3335
+ const again = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3336
+ rowId = again?.id != null ? String(again.id) : "";
3327
3337
  }
3338
+ if (!rowId) continue;
3339
+ const regNew = {
3340
+ serviceName: String(row?.service_name ?? serviceNameRaw),
3341
+ serviceGroup,
3342
+ queueName: options.queueName,
3343
+ target: options.target,
3344
+ rowId,
3345
+ registryTable,
3346
+ instanceNumber
3347
+ };
3348
+ context.servicesRegistry = regNew;
3349
+ context.runnerHeartbeat = regNew;
3350
+ context.logger.info?.(
3351
+ `[services-registry] registered name=${regNew.serviceName} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3352
+ );
3353
+ return regNew;
3328
3354
  } catch (error) {
3329
3355
  if (!isUniqueViolation(error)) {
3330
3356
  throw error;
3331
3357
  }
3358
+ context.logger.warn?.(`[services-registry] insert race on "${serviceNameRaw}", retrying (attempt ${attempt + 1})`);
3332
3359
  }
3333
3360
  }
3334
- if (!inserted) {
3335
- throw new Error(
3336
- `[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
3337
- );
3338
- }
3339
- identity.serviceName = inserted.service_name;
3340
- await writeIdentityFile(identityPath, identity);
3341
- const regNew = {
3342
- instanceId,
3343
- serviceName: inserted.service_name,
3344
- serviceGroup,
3345
- queue: options.queue,
3346
- target: options.target,
3347
- rowId: inserted.id
3348
- };
3349
- context.servicesRegistry = regNew;
3350
- context.runnerHeartbeat = regNew;
3351
- context.logger.info?.(
3352
- `[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
3361
+ throw new Error(
3362
+ `[services-registry] could not allocate a registry row for group=${serviceGroup} queue=${options.queueName} after ${MAX_ATTEMPTS} attempts`
3353
3363
  );
3354
- return {
3355
- instanceId,
3356
- serviceName: inserted.service_name,
3357
- serviceGroup,
3358
- queue: options.queue,
3359
- target: options.target,
3360
- rowId: inserted.id,
3361
- registryTable
3362
- };
3363
3364
  }
3364
3365
  async function touchServicesRegistry(context, registration) {
3365
3366
  const db = getDb2(context);
3366
- const hostname = import_node_os.default.hostname();
3367
+ const serverName = import_node_os.default.hostname();
3367
3368
  const pid = typeof process.pid === "number" ? process.pid : null;
3368
- await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
3369
+ await db(registration.registryTable).where({ id: registration.rowId }).update({
3369
3370
  last_seen_at: db.fn.now(),
3370
- hostname,
3371
+ server_name: serverName,
3371
3372
  pid
3372
3373
  });
3373
3374
  }
3374
3375
  async function unregisterServicesRegistry(context, registration) {
3375
3376
  const db = getDb2(context);
3376
- await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
3377
- context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
3377
+ await db(registration.registryTable).where({ id: registration.rowId }).delete();
3378
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} id=${registration.rowId}`);
3378
3379
  }
3379
3380
 
3380
- // src/filedatabase/index.ts
3381
+ // src/tasks/taskLogs.js
3382
+ var import_node_path = __toESM(require("path"), 1);
3383
+
3384
+ // src/filedatabase/index.js
3381
3385
  var import_fs4 = __toESM(require("fs"), 1);
3382
3386
  var import_path4 = __toESM(require("path"), 1);
3383
3387
 
3384
- // src/filedatabase/serializers.ts
3388
+ // src/filedatabase/serializers.js
3385
3389
  function detectDataType(data) {
3386
3390
  if (Array.isArray(data)) {
3387
3391
  return "json-array";
@@ -3413,7 +3417,7 @@ function deserializeData(rawData, dataType) {
3413
3417
  }
3414
3418
  }
3415
3419
 
3416
- // src/filedatabase/index.ts
3420
+ // src/filedatabase/index.js
3417
3421
  var FileDatabase = class _FileDatabase {
3418
3422
  basePath;
3419
3423
  namespace;
@@ -3499,7 +3503,7 @@ var FileDatabase = class _FileDatabase {
3499
3503
  if (errors.length) {
3500
3504
  throw new FileDatabaseError(`[FileDatabase] ${errors.join("; ")}`);
3501
3505
  }
3502
- let parts = [this.basePath, this.namespace];
3506
+ const parts = [this.basePath, this.namespace];
3503
3507
  if (this.tableName) {
3504
3508
  parts.push(...this.tableName.split("/"));
3505
3509
  }
@@ -3992,14 +3996,17 @@ var FileDatabase = class _FileDatabase {
3992
3996
  * Prepare the instance for read or write operations
3993
3997
  * This discovers state and sets up internal members based on mode and current data
3994
3998
  */
3995
- async prepare({ write, read, version }) {
3999
+ async prepare(options) {
4000
+ const { write, read, version, deferInitialVersion } = options;
3996
4001
  if (write) {
3997
4002
  if (this.versioned) {
3998
4003
  if (this.currentVersion === null) {
3999
- await this.makeNewVersion();
4000
- this.metadata = this.getDefaultMetadata();
4001
- this.metadata.version = this.currentVersion;
4002
- this.makeNewFile();
4004
+ if (!deferInitialVersion) {
4005
+ await this.makeNewVersion();
4006
+ this.metadata = this.getDefaultMetadata();
4007
+ this.metadata.version = this.currentVersion;
4008
+ this.makeNewFile();
4009
+ }
4003
4010
  } else {
4004
4011
  if (!this.metadata.files.length) {
4005
4012
  this.metadata = await this.figureMetadata(this.currentVersion);
@@ -4109,7 +4116,7 @@ var FileDatabase = class _FileDatabase {
4109
4116
  if (options.forceNewVersion && !this.versioned) {
4110
4117
  throw new FileDatabaseError("Cannot use forceNewVersion in non-versioned mode");
4111
4118
  }
4112
- await this.prepare({ write: true });
4119
+ await this.prepare({ write: true, deferInitialVersion: !!(options.forceNewVersion && this.versioned) });
4113
4120
  const incomingDataType = detectDataType(data);
4114
4121
  this.metadata.dataType = incomingDataType;
4115
4122
  if (options.forceNewVersion) {
@@ -4403,7 +4410,7 @@ var FileDatabase = class _FileDatabase {
4403
4410
  }
4404
4411
  };
4405
4412
 
4406
- // src/tasks/taskLogs.ts
4413
+ // src/tasks/taskLogs.js
4407
4414
  function getLogsState(context) {
4408
4415
  const holder = context;
4409
4416
  if (holder.__tasksLogsState) return holder.__tasksLogsState;
@@ -4456,6 +4463,89 @@ function getLogsState(context) {
4456
4463
  holder.__tasksLogsState = state;
4457
4464
  return state;
4458
4465
  }
4466
+ function ipcLogTargetKey(target) {
4467
+ const bp = target.basePath ?? "";
4468
+ const ns = target.namespace ?? "";
4469
+ return `${bp}::${ns}::${target.tableName}`;
4470
+ }
4471
+ function ipcFileLogsTableNameForSourceResource(source, resource) {
4472
+ const seg = (s) => {
4473
+ const t = String(s).trim().replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
4474
+ return t.length ? t : "x";
4475
+ };
4476
+ return `${seg(source)}/${seg(resource)}`;
4477
+ }
4478
+ async function readTaskIpcLogsSnapshot(context, options) {
4479
+ const holder = context;
4480
+ const basePath = holder.params?.get?.("tasksLogsBasePath") ?? "./data";
4481
+ const namespace = holder.params?.get?.("tasksLogsNamespace") ?? "tasks-logs";
4482
+ const tableName = ipcFileLogsTableNameForSourceResource(options.source, options.resource);
4483
+ const tail = Math.max(1, Math.min(1e4, Number(options.tail) > 0 ? Number(options.tail) : 100));
4484
+ const fd = new FileDatabase({
4485
+ basePath,
4486
+ namespace,
4487
+ tableName,
4488
+ versioned: true,
4489
+ useMetadata: true,
4490
+ maxVersions: 30,
4491
+ pageSize: 2e3,
4492
+ logger: holder.logger
4493
+ });
4494
+ const versions = await fd.getVersions();
4495
+ if (versions.length === 0) {
4496
+ return { records: [], latestTs: null };
4497
+ }
4498
+ const latest = versions[versions.length - 1];
4499
+ const raw = await fd.read({ version: latest });
4500
+ const arr = Array.isArray(raw) ? raw : [];
4501
+ let filtered = arr;
4502
+ if (options.afterTs && String(options.afterTs).trim()) {
4503
+ const cut = String(options.afterTs).trim();
4504
+ filtered = arr.filter((r) => r && typeof r.ts === "string" && String(r.ts) > cut);
4505
+ }
4506
+ let latestTs = null;
4507
+ for (const r of filtered) {
4508
+ const ts = typeof r?.ts === "string" ? String(r.ts) : null;
4509
+ if (ts && (!latestTs || ts > latestTs)) latestTs = ts;
4510
+ }
4511
+ const incremental = !!(options.afterTs && String(options.afterTs).trim());
4512
+ const maxReturn = incremental ? 1e4 : tail;
4513
+ const sliced = filtered.length > maxReturn ? filtered.slice(-maxReturn) : filtered;
4514
+ return { records: sliced, latestTs };
4515
+ }
4516
+ function getLogsStateForTarget(context, target) {
4517
+ const holder = context;
4518
+ const enabledRaw = holder.params?.get?.("tasksLogsEnabled");
4519
+ const enabled = enabledRaw === void 0 ? true : !!enabledRaw;
4520
+ if (!enabled) return null;
4521
+ if (!holder.__tasksLogsTargetStates) holder.__tasksLogsTargetStates = /* @__PURE__ */ new Map();
4522
+ const map = holder.__tasksLogsTargetStates;
4523
+ const key = ipcLogTargetKey(target);
4524
+ if (map.has(key)) return map.get(key);
4525
+ const basePath = target.basePath ?? (holder.params?.get?.("tasksLogsBasePath") || "./data");
4526
+ const namespace = target.namespace ?? (holder.params?.get?.("tasksLogsNamespace") || "tasks-logs");
4527
+ const maxVersionsRaw = Number(holder.params?.get?.("tasksLogsMaxVersions"));
4528
+ const pageSizeRaw = Number(holder.params?.get?.("tasksLogsPageSize"));
4529
+ const db = new FileDatabase({
4530
+ basePath,
4531
+ namespace,
4532
+ tableName: target.tableName,
4533
+ versioned: true,
4534
+ useMetadata: true,
4535
+ maxVersions: Number.isFinite(maxVersionsRaw) && maxVersionsRaw > 0 ? maxVersionsRaw : 20,
4536
+ pageSize: Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? pageSizeRaw : 2e3,
4537
+ logger: holder.logger
4538
+ });
4539
+ const state = {
4540
+ db,
4541
+ errorDb: null,
4542
+ queue: Promise.resolve(),
4543
+ initialized: false,
4544
+ errorInitialized: false
4545
+ };
4546
+ map.set(key, state);
4547
+ return state;
4548
+ }
4459
4549
  function isErrorPayload(payload) {
4460
4550
  if (!payload) return false;
4461
4551
  if (typeof payload === "object") {
@@ -4475,14 +4565,26 @@ function buildLogRecord(task, payload) {
4475
4565
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4476
4566
  opid: task.opid ?? null,
4477
4567
  taskId: task.id,
4478
- taskName: task.task,
4479
- target: task.target,
4568
+ taskName: task.name,
4569
+ target: task.service_group,
4480
4570
  source: typeof params.source === "string" ? params.source : null,
4481
4571
  resource: typeof params.resource === "string" ? params.resource : null,
4482
4572
  payload
4483
4573
  };
4484
4574
  }
4485
- function appendTaskIpcLog(context, task, payload) {
4575
+ function appendTaskIpcLog(context, task, payload, target) {
4576
+ if (target) {
4577
+ const state2 = getLogsStateForTarget(context, target);
4578
+ if (!state2?.db) return;
4579
+ const record2 = buildLogRecord(task, payload);
4580
+ state2.queue = state2.queue.then(async () => {
4581
+ await state2.db.write([record2], { forceNewVersion: !state2.initialized });
4582
+ state2.initialized = true;
4583
+ }).catch((error) => {
4584
+ context.logger.warn?.("[tasks] failed to persist IPC log entry (targeted):", error);
4585
+ });
4586
+ return;
4587
+ }
4486
4588
  const state = getLogsState(context);
4487
4589
  if (!state.db && !state.errorDb) return;
4488
4590
  const record = buildLogRecord(task, payload);
@@ -4500,83 +4602,280 @@ function appendTaskIpcLog(context, task, payload) {
4500
4602
  });
4501
4603
  }
4502
4604
 
4503
- // src/tasks/time-matcher.ts
4504
- var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
4505
- function resolveAsterisks(field, range) {
4506
- return field.includes("*") ? field.replace("*", range) : field;
4507
- }
4508
- function resolveRanges(field) {
4509
- const regex = /(\d+)-(\d+)/;
4510
- let current = field;
4511
- while (true) {
4512
- const match = regex.exec(current);
4513
- if (!match) break;
4514
- const raw = match[0];
4515
- let first = Number(match[1]);
4516
- let last = Number(match[2]);
4517
- if (last < first) {
4518
- [first, last] = [last, first];
4519
- }
4520
- const values = [];
4521
- for (let i = first; i <= last; i += 1) {
4522
- values.push(i);
4523
- }
4524
- current = current.replace(raw, values.join(","));
4525
- }
4526
- return current;
4527
- }
4528
- function resolveSteps(field) {
4529
- const match = /^(.+)\/(\d+)$/.exec(field);
4530
- if (!match) return field;
4531
- const base = match[1];
4532
- const step = Number(match[2]);
4533
- if (!Number.isFinite(step) || step <= 0) return field;
4534
- return base.split(",").map((v) => Number(v)).filter((v) => Number.isFinite(v) && v % step === 0).join(",");
4535
- }
4536
- function convertPattern(pattern) {
4537
- const parts = pattern.trim().split(/\s+/);
4538
- if (parts.length !== 6) {
4539
- throw new Error(`Invalid schedule "${pattern}". Expected 6 fields: sec min hour day month weekday`);
4540
- }
4541
- return parts.map((field, idx) => resolveAsterisks(field, RANGES[idx])).map((field) => resolveRanges(field)).map((field) => resolveSteps(field));
4542
- }
4543
- function fieldMatches(field, value) {
4544
- const allowed = field.split(",").map((v) => Number(v));
4545
- return allowed.includes(value);
4546
- }
4547
- function timeMatcher(pattern, date = /* @__PURE__ */ new Date()) {
4548
- const parsed = convertPattern(pattern);
4549
- return fieldMatches(parsed[0], date.getSeconds()) && fieldMatches(parsed[1], date.getMinutes()) && fieldMatches(parsed[2], date.getHours()) && fieldMatches(parsed[3], date.getDate()) && fieldMatches(parsed[4], date.getMonth() + 1) && fieldMatches(parsed[5], date.getDay());
4550
- }
4551
-
4552
- // src/tasks/TaskMaster.ts
4553
- var TaskMaster = class {
4554
- context;
4555
- task;
4605
+ // src/tasks/AbstractTask.js
4606
+ var AbstractTask = class _AbstractTask {
4607
+ /**
4608
+ * Whether `send-task` should wait for completion (and print a result
4609
+ * report) when no explicit `--wait` / `--noWait` flag is given. Defaults
4610
+ * to false; short-lived probe tasks (e.g. `ping`) override to true.
4611
+ *
4612
+ * @type {boolean}
4613
+ */
4614
+ static defaultWaitForResult = false;
4615
+ /**
4616
+ * @param {object} context Runner context (db, logger, params, emitter...).
4617
+ * @param {object} task Task row as claimed from the queue.
4618
+ */
4556
4619
  constructor(context, task) {
4557
4620
  this.context = context;
4558
4621
  this.task = task;
4559
4622
  }
4623
+ /**
4624
+ * Return a short reason string when the task should be deferred (e.g. "locked
4625
+ * by source"), or `false`/falsy when it is free to run. Default: always `false`.
4626
+ *
4627
+ * @returns {string | false | Promise<string | false>}
4628
+ */
4560
4629
  cantRunReason() {
4561
4630
  return false;
4562
4631
  }
4632
+ /**
4633
+ * Called by the runner when a stop has been requested. Subclasses running
4634
+ * long loops should flip a flag here and check it between iterations.
4635
+ *
4636
+ * @param {number} [_allowanceMs] Grace period the runner promises before hard exit.
4637
+ */
4563
4638
  requestStop(_allowanceMs) {
4564
4639
  }
4640
+ /**
4641
+ * Perform the task. Must be implemented by subclasses.
4642
+ *
4643
+ * @param {(progress: unknown) => Promise<void>} _reportProgress
4644
+ * Updates the DB `progress` column. Accepts any serializable value;
4645
+ * strings are stored verbatim, objects are JSON-stringified.
4646
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4647
+ */
4648
+ async run(_reportProgress) {
4649
+ throw new Error("AbstractTask.run must be implemented by subclass");
4650
+ }
4651
+ /**
4652
+ * Resolve a complete row payload for this task — envelope fields (queue,
4653
+ * priority, targeting, schedule…) plus the inner `params` blob produced by
4654
+ * {@link AbstractTask.resolveCustomParams}. Output shape matches
4655
+ * {@link enqueueTask}'s `options` argument, so the typical call is:
4656
+ *
4657
+ * const payload = await TaskClass.resolveParams(context, { name });
4658
+ * await enqueueTask(context, payload);
4659
+ *
4660
+ * Validation failures throw {@link ParamError} so the script aborts before
4661
+ * a malformed row hits the DB.
4662
+ *
4663
+ * @param {object} context
4664
+ * @param {Record<string, unknown>} [overrides] Partial overrides; takes precedence over CLI/env.
4665
+ * Recognised keys: `name`, `queueName`, `priority`, `serviceGroup`,
4666
+ * `serviceName`, `instanceNumber`, `serverName`, `opid`, `schedule`,
4667
+ * `nextRunAt`, plus `params` (object — overlay onto inner blob).
4668
+ * @returns {Promise<object>}
4669
+ */
4670
+ static async resolveParams(context, overrides = {}) {
4671
+ const main = _AbstractTask._resolveMainFields(context, overrides);
4672
+ const params = await this.resolveCustomParams(context, overrides);
4673
+ return { ...main, params };
4674
+ }
4675
+ /**
4676
+ * Resolve the inner JSON blob stored in the `params` column. Default
4677
+ * implementation passes through `--paramsJson` (parsed as a JSON object)
4678
+ * overlaid with `overrides.params` when supplied; returns `null` when
4679
+ * neither is provided.
4680
+ *
4681
+ * Subclasses with typed fields should override and call
4682
+ * {@link AbstractTask._mergeTypedParams} for layered CLI/env/JSON/override
4683
+ * resolution, then validate and throw {@link ParamError} on bad input.
4684
+ *
4685
+ * @param {object} context
4686
+ * @param {Record<string, unknown>} [overrides]
4687
+ * @returns {Promise<object|null>}
4688
+ */
4689
+ static async resolveCustomParams(context, overrides = {}) {
4690
+ return _AbstractTask._defaultParamsBlob(context, overrides);
4691
+ }
4692
+ /**
4693
+ * Read main task envelope fields from `context.params` (CLI/env), with
4694
+ * any matching key on `overrides` taking precedence. Internal; called by
4695
+ * {@link AbstractTask.resolveParams}.
4696
+ *
4697
+ * @param {object} context
4698
+ * @param {Record<string, unknown>} [overrides]
4699
+ * @returns {object}
4700
+ */
4701
+ static _resolveMainFields(context, overrides = {}) {
4702
+ const defs2 = {
4703
+ queueName: "string default tasks",
4704
+ priority: "number default 50",
4705
+ serviceGroup: "string",
4706
+ serviceName: "string",
4707
+ instanceNumber: "number",
4708
+ serverName: "string",
4709
+ opid: "string",
4710
+ schedule: "string"
4711
+ };
4712
+ const cli = context.params.getAllForModule("task-envelope", defs2);
4713
+ const name = emptyToUndef(overrides.name) ?? emptyToUndef(context.params.get("name", "string"));
4714
+ if (!name) {
4715
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
4716
+ }
4717
+ let instanceNumber;
4718
+ const rawInstance = overrides.instanceNumber ?? cli.instanceNumber;
4719
+ if (rawInstance !== void 0 && rawInstance !== null && String(rawInstance).trim() !== "") {
4720
+ const n = Number(rawInstance);
4721
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
4722
+ throw new ParamError("--instanceNumber must be a positive integer when set");
4723
+ }
4724
+ instanceNumber = n;
4725
+ } else {
4726
+ instanceNumber = null;
4727
+ }
4728
+ const priorityRaw = overrides.priority ?? cli.priority ?? 50;
4729
+ const priority = Number(priorityRaw);
4730
+ if (!Number.isFinite(priority)) {
4731
+ throw new ParamError(`--priority must be a number (got ${JSON.stringify(priorityRaw)})`);
4732
+ }
4733
+ return {
4734
+ name,
4735
+ queueName: overrides.queueName ?? emptyToUndef(cli.queueName) ?? "tasks",
4736
+ priority,
4737
+ serviceGroup: emptyToUndef(overrides.serviceGroup) ?? emptyToUndef(cli.serviceGroup) ?? null,
4738
+ serviceName: emptyToUndef(overrides.serviceName) ?? emptyToUndef(cli.serviceName) ?? null,
4739
+ instanceNumber,
4740
+ serverName: emptyToUndef(overrides.serverName) ?? emptyToUndef(cli.serverName) ?? null,
4741
+ opid: emptyToUndef(overrides.opid) ?? emptyToUndef(cli.opid) ?? null,
4742
+ schedule: emptyToUndef(overrides.schedule) ?? emptyToUndef(cli.schedule) ?? null,
4743
+ nextRunAt: overrides.nextRunAt ?? null
4744
+ };
4745
+ }
4746
+ /**
4747
+ * Default inner-params resolver: parses `--paramsJson` (must be a JSON
4748
+ * object), then overlays `overrides.params` on top. Returns `null` when
4749
+ * neither is provided.
4750
+ *
4751
+ * @param {object} context
4752
+ * @param {Record<string, unknown>} [overrides]
4753
+ * @returns {object|null}
4754
+ */
4755
+ static _defaultParamsBlob(context, overrides = {}) {
4756
+ const cli = context.params.getAllForModule("task-params", { paramsJson: "string" });
4757
+ const fromJson = parseParamsJson(cli.paramsJson);
4758
+ const fromOverride = pickParamsObject(overrides);
4759
+ if (!fromJson && !fromOverride) return null;
4760
+ return { ...fromJson ?? {}, ...fromOverride ?? {} };
4761
+ }
4762
+ /**
4763
+ * Helper for subclass `resolveCustomParams` overrides. Reads typed CLI
4764
+ * params (per `defs`) plus `--paramsJson` under a module namespace, then
4765
+ * merges them with explicit `overrides.params` in increasing priority:
4766
+ *
4767
+ * typed CLI flags → --paramsJson → overrides.params
4768
+ *
4769
+ * Undefined values are dropped so defaults declared in `defs` aren't
4770
+ * overwritten by missing-flag noise. Returns the merged object; the
4771
+ * caller is responsible for validation and throwing `ParamError`.
4772
+ *
4773
+ * @param {object} context
4774
+ * @param {string} moduleName Namespace for `--showUsedParams` grouping.
4775
+ * @param {Record<string, string>} defs Param defs in `getAllForModule` syntax.
4776
+ * @param {Record<string, unknown>} [overrides] As passed to `resolveCustomParams`.
4777
+ * @returns {Record<string, unknown>}
4778
+ */
4779
+ static _mergeTypedParams(context, moduleName, defs2, overrides = {}) {
4780
+ const fullDefs = { ...defs2, paramsJson: "string" };
4781
+ const cliRaw = context.params.getAllForModule(moduleName, fullDefs);
4782
+ const fromJson = parseParamsJson(cliRaw.paramsJson) ?? {};
4783
+ const fromCli = {};
4784
+ for (const [k, v] of Object.entries(cliRaw)) {
4785
+ if (k === "paramsJson") continue;
4786
+ if (v !== void 0 && v !== null) fromCli[k] = v;
4787
+ }
4788
+ const fromOverride = pickParamsObject(overrides) ?? {};
4789
+ return { ...fromCli, ...fromJson, ...fromOverride };
4790
+ }
4565
4791
  };
4792
+ function emptyToUndef(s) {
4793
+ if (s === void 0 || s === null) return void 0;
4794
+ if (typeof s !== "string") return s;
4795
+ const t = s.trim();
4796
+ return t.length ? t : void 0;
4797
+ }
4798
+ function parseParamsJson(raw) {
4799
+ if (raw == null) return null;
4800
+ const t = String(raw).trim();
4801
+ if (!t) return null;
4802
+ let parsed;
4803
+ try {
4804
+ parsed = JSON.parse(t);
4805
+ } catch (e) {
4806
+ throw new ParamError(`--paramsJson: not valid JSON: ${e?.message ?? String(e)}`);
4807
+ }
4808
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4809
+ throw new ParamError("--paramsJson must be a JSON object");
4810
+ }
4811
+ return parsed;
4812
+ }
4813
+ function pickParamsObject(overrides) {
4814
+ const p = overrides?.params;
4815
+ if (p && typeof p === "object" && !Array.isArray(p)) return p;
4816
+ return void 0;
4817
+ }
4566
4818
 
4567
- // src/tasks/coreTasks/TaskPing.ts
4568
- var TaskPing = class extends TaskMaster {
4819
+ // src/tasks/coreTasks/TaskPing.js
4820
+ var TaskPing = class extends AbstractTask {
4821
+ /** Default-wait so `send-task --name=ping` prints a result without `--wait`. */
4822
+ static defaultWaitForResult = true;
4823
+ /** Ping takes no params. */
4824
+ static async resolveCustomParams() {
4825
+ return null;
4826
+ }
4827
+ /**
4828
+ * @returns {Promise<{ success: true, results: "pong" }>}
4829
+ */
4569
4830
  async run() {
4570
4831
  this.context.logger.info?.(`[TaskPing] pong (${this.task.id})`);
4571
4832
  return { success: true, results: "pong" };
4572
4833
  }
4573
4834
  };
4574
4835
 
4575
- // src/tasks/coreTasks/TaskSampleProcess.ts
4576
- var TaskSampleProcess = class extends TaskMaster {
4577
- stopRequested = false;
4578
- stopAllowanceMs = 0;
4579
- stopDecisionLogged = false;
4836
+ // src/tasks/coreTasks/TaskSampleProcess.js
4837
+ var TaskSampleProcess = class extends AbstractTask {
4838
+ /**
4839
+ * @param {object} context
4840
+ * @param {Record<string, unknown>} [overrides]
4841
+ * @returns {Promise<{ total: number, delay: number, name?: string }>}
4842
+ */
4843
+ static async resolveCustomParams(context, overrides = {}) {
4844
+ const merged = AbstractTask._mergeTypedParams(context, "task-sample-process", {
4845
+ total: "number default 10",
4846
+ delay: "number default 1000",
4847
+ name: "string"
4848
+ }, overrides);
4849
+ const total = Number(merged.total);
4850
+ const delay = Number(merged.delay);
4851
+ if (!Number.isInteger(total) || total <= 0) {
4852
+ throw new ParamError(`sampleProcess: param "total" must be a positive integer (got ${JSON.stringify(merged.total)})`);
4853
+ }
4854
+ if (!Number.isInteger(delay) || delay < 0) {
4855
+ throw new ParamError(`sampleProcess: param "delay" must be an integer >= 0 (got ${JSON.stringify(merged.delay)})`);
4856
+ }
4857
+ const out = { total, delay };
4858
+ if (typeof merged.name === "string" && merged.name.trim()) {
4859
+ out.name = merged.name.trim();
4860
+ }
4861
+ return out;
4862
+ }
4863
+ /**
4864
+ * @param {object} context
4865
+ * @param {object} task
4866
+ */
4867
+ constructor(context, task) {
4868
+ super(context, task);
4869
+ this.stopRequested = false;
4870
+ this.stopAllowanceMs = 0;
4871
+ this.stopDecisionLogged = false;
4872
+ }
4873
+ /**
4874
+ * Runner-facing stop signal. Records the allowance window so the main loop
4875
+ * can decide per-iteration whether to finish or abort early.
4876
+ *
4877
+ * @param {number} allowanceMs
4878
+ */
4580
4879
  requestStop(allowanceMs) {
4581
4880
  this.stopRequested = true;
4582
4881
  this.stopAllowanceMs = Number.isFinite(allowanceMs) && allowanceMs > 0 ? allowanceMs : 0;
@@ -4584,6 +4883,14 @@ var TaskSampleProcess = class extends TaskMaster {
4584
4883
  `[TaskSampleProcess] stop signal received (${this.task.id}), allowanceMs=${this.stopAllowanceMs}`
4585
4884
  );
4586
4885
  }
4886
+ /**
4887
+ * Iterate `total` times, sleeping `delay` ms between ticks and reporting
4888
+ * progress every iteration. Validates params up front; invalid values short-
4889
+ * circuit to a structured failure without starting the loop.
4890
+ *
4891
+ * @param {(progress: object) => Promise<void>} reportProgress
4892
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4893
+ */
4587
4894
  async run(reportProgress) {
4588
4895
  const totalRaw = this.task?.params?.total ?? 10;
4589
4896
  const delayRaw = this.task?.params?.delay ?? 1e3;
@@ -4665,7 +4972,7 @@ var TaskSampleProcess = class extends TaskMaster {
4665
4972
  }
4666
4973
  };
4667
4974
 
4668
- // src/tasks/coreTasks/TaskShellCommand.ts
4975
+ // src/tasks/coreTasks/TaskShellCommand.js
4669
4976
  var import_node_child_process = require("child_process");
4670
4977
  function runShellCommand(command, cwd) {
4671
4978
  return new Promise((resolve2, reject) => {
@@ -4695,7 +5002,27 @@ function runShellCommand(command, cwd) {
4695
5002
  });
4696
5003
  });
4697
5004
  }
4698
- var TaskShellCommand = class extends TaskMaster {
5005
+ var TaskShellCommand = class extends AbstractTask {
5006
+ /**
5007
+ * @param {object} context
5008
+ * @param {Record<string, unknown>} [overrides]
5009
+ * @returns {Promise<{ command: string, cwd?: string }>}
5010
+ */
5011
+ static async resolveCustomParams(context, overrides = {}) {
5012
+ const merged = AbstractTask._mergeTypedParams(context, "task-shell", {
5013
+ command: "string",
5014
+ cwd: "string"
5015
+ }, overrides);
5016
+ const command = typeof merged.command === "string" ? merged.command.trim() : "";
5017
+ if (!command) {
5018
+ throw new ParamError('shellCommand: param "command" must be a non-empty string');
5019
+ }
5020
+ const cwd = typeof merged.cwd === "string" && merged.cwd.trim() ? merged.cwd.trim() : null;
5021
+ return cwd ? { command, cwd } : { command };
5022
+ }
5023
+ /**
5024
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5025
+ */
4699
5026
  async run() {
4700
5027
  const params = this.task?.params;
4701
5028
  const commandRaw = typeof params === "string" ? params : params?.command;
@@ -4744,9 +5071,9 @@ var TaskShellCommand = class extends TaskMaster {
4744
5071
  }
4745
5072
  };
4746
5073
 
4747
- // src/tasks/coreTasks/TaskSystemInfo.ts
5074
+ // src/tasks/coreTasks/TaskSystemInfo.js
4748
5075
  var import_node_os2 = __toESM(require("os"), 1);
4749
- var import_promises2 = __toESM(require("fs/promises"), 1);
5076
+ var import_promises = __toESM(require("fs/promises"), 1);
4750
5077
  function toGb(valueBytes) {
4751
5078
  return `${(valueBytes / 1024 ** 3).toFixed(2)} GB`;
4752
5079
  }
@@ -4754,7 +5081,7 @@ function toMb(valueBytes) {
4754
5081
  return `${(valueBytes / 1024 ** 2).toFixed(2)} MB`;
4755
5082
  }
4756
5083
  async function getDiskStats() {
4757
- const stats = await import_promises2.default.statfs("/");
5084
+ const stats = await import_promises.default.statfs("/");
4758
5085
  const total = Number(stats.bsize) * Number(stats.blocks);
4759
5086
  const free = Number(stats.bsize) * Number(stats.bavail);
4760
5087
  const used = total - free;
@@ -4764,7 +5091,16 @@ async function getDiskStats() {
4764
5091
  free: toGb(free)
4765
5092
  };
4766
5093
  }
4767
- var TaskSystemInfo = class extends TaskMaster {
5094
+ var TaskSystemInfo = class extends AbstractTask {
5095
+ /** Same UX expectation as `ping` — short probe, print the result. */
5096
+ static defaultWaitForResult = true;
5097
+ /** systemInfo takes no params. */
5098
+ static async resolveCustomParams() {
5099
+ return null;
5100
+ }
5101
+ /**
5102
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5103
+ */
4768
5104
  async run() {
4769
5105
  try {
4770
5106
  const totalMemory = import_node_os2.default.totalmem();
@@ -4816,8 +5152,31 @@ var TaskSystemInfo = class extends TaskMaster {
4816
5152
  }
4817
5153
  };
4818
5154
 
4819
- // src/tasks/coreTasks/TaskSumAB.ts
4820
- var TaskSumAB = class extends TaskMaster {
5155
+ // src/tasks/coreTasks/TaskSumAB.js
5156
+ var TaskSumAB = class extends AbstractTask {
5157
+ /** Short, deterministic — wait by default so callers see the sum. */
5158
+ static defaultWaitForResult = true;
5159
+ /**
5160
+ * @param {object} context
5161
+ * @param {Record<string, unknown>} [overrides]
5162
+ * @returns {Promise<{ a: number, b: number }>}
5163
+ */
5164
+ static async resolveCustomParams(context, overrides = {}) {
5165
+ const merged = AbstractTask._mergeTypedParams(context, "task-sumab", {
5166
+ a: "number",
5167
+ b: "number"
5168
+ }, overrides);
5169
+ if (typeof merged.a !== "number" || Number.isNaN(merged.a)) {
5170
+ throw new ParamError(`taskSumAB: param "a" must be a valid number (got ${JSON.stringify(merged.a)})`);
5171
+ }
5172
+ if (typeof merged.b !== "number" || Number.isNaN(merged.b)) {
5173
+ throw new ParamError(`taskSumAB: param "b" must be a valid number (got ${JSON.stringify(merged.b)})`);
5174
+ }
5175
+ return { a: merged.a, b: merged.b };
5176
+ }
5177
+ /**
5178
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5179
+ */
4821
5180
  async run() {
4822
5181
  const a = this.task?.params?.a;
4823
5182
  const b = this.task?.params?.b;
@@ -4848,8 +5207,46 @@ var TaskSumAB = class extends TaskMaster {
4848
5207
  }
4849
5208
  };
4850
5209
 
4851
- // src/tasks/coreTasks/TaskStopRunner.ts
4852
- var TaskStopRunner = class extends TaskMaster {
5210
+ // src/tasks/coreTasks/TaskStopRunner.js
5211
+ var TaskStopRunner = class extends AbstractTask {
5212
+ /**
5213
+ * Stop tasks must target a concrete instance — without `serviceName` the
5214
+ * row would race against any worker on the queue. Layered on top of the
5215
+ * envelope built by {@link AbstractTask.resolveParams}.
5216
+ *
5217
+ * @param {object} context
5218
+ * @param {Record<string, unknown>} [overrides]
5219
+ * @returns {Promise<object>}
5220
+ */
5221
+ static async resolveParams(context, overrides = {}) {
5222
+ const main = await super.resolveParams(context, overrides);
5223
+ if (!main.serviceName) {
5224
+ throw new ParamError(
5225
+ "stop/stopRunner requires --serviceName (registry instance name; optional --serviceGroup, --instanceNumber, --serverName to narrow targeting)"
5226
+ );
5227
+ }
5228
+ return main;
5229
+ }
5230
+ /**
5231
+ * @param {object} context
5232
+ * @param {Record<string, unknown>} [overrides]
5233
+ * @returns {Promise<{ allowanceMs: number }>}
5234
+ */
5235
+ static async resolveCustomParams(context, overrides = {}) {
5236
+ const merged = AbstractTask._mergeTypedParams(context, "task-stop", {
5237
+ allowanceMs: "number default 5000"
5238
+ }, overrides);
5239
+ const allowanceMs = Number(merged.allowanceMs);
5240
+ if (!Number.isFinite(allowanceMs) || allowanceMs < 0) {
5241
+ throw new ParamError(
5242
+ `stopRunner: allowanceMs must be a non-negative number (got ${JSON.stringify(merged.allowanceMs)})`
5243
+ );
5244
+ }
5245
+ return { allowanceMs };
5246
+ }
5247
+ /**
5248
+ * @returns {Promise<{ success: true, results: { stopRunner: true, allowanceMs: number, message: string } }>}
5249
+ */
4853
5250
  async run() {
4854
5251
  const allowanceMs = Number(this.task?.params?.allowanceMs ?? 5e3);
4855
5252
  this.context.logger.warn?.(`[TaskStopRunner] stop requested (allowanceMs=${allowanceMs})`);
@@ -4864,42 +5261,200 @@ var TaskStopRunner = class extends TaskMaster {
4864
5261
  }
4865
5262
  };
4866
5263
 
4867
- // src/tasks/TasksRegistry.ts
5264
+ // src/tasks/coreTasks/TaskGetLogs.js
5265
+ var TaskGetLogs = class extends AbstractTask {
5266
+ /**
5267
+ * @param {object} context
5268
+ * @param {Record<string, unknown>} [overrides]
5269
+ * @returns {Promise<{ source: string, resource: string, tail: number, afterTs?: string }>}
5270
+ */
5271
+ static async resolveCustomParams(context, overrides = {}) {
5272
+ const merged = AbstractTask._mergeTypedParams(context, "task-get-logs", {
5273
+ source: "string",
5274
+ resource: "string",
5275
+ tail: "number default 100",
5276
+ afterTs: "string"
5277
+ }, overrides);
5278
+ const source = typeof merged.source === "string" ? merged.source.trim() : "";
5279
+ const resource = typeof merged.resource === "string" ? merged.resource.trim() : "";
5280
+ if (!source) throw new ParamError('getLogs: param "source" is required');
5281
+ if (!resource) throw new ParamError('getLogs: param "resource" is required');
5282
+ let tail = Number(merged.tail);
5283
+ if (!Number.isFinite(tail) || tail < 1) tail = 100;
5284
+ tail = Math.min(1e4, Math.max(1, Math.floor(tail)));
5285
+ const out = { source, resource, tail };
5286
+ if (typeof merged.afterTs === "string" && merged.afterTs.trim()) {
5287
+ out.afterTs = merged.afterTs.trim();
5288
+ }
5289
+ return out;
5290
+ }
5291
+ /**
5292
+ * @param {unknown} _reportProgress Unused (single-shot read, no progress events).
5293
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5294
+ */
5295
+ async run(_reportProgress) {
5296
+ const p = this.task.params ?? {};
5297
+ const source = String(p.source ?? "").trim();
5298
+ const resource = String(p.resource ?? "").trim();
5299
+ const tail = Math.max(1, Math.min(1e4, Number(p.tail) > 0 ? Number(p.tail) : 100));
5300
+ const afterTs = p.afterTs != null && String(p.afterTs).trim() ? String(p.afterTs).trim() : null;
5301
+ if (!source || !resource) {
5302
+ return {
5303
+ success: false,
5304
+ results: { error: 'getLogs requires params "source" and "resource"' }
5305
+ };
5306
+ }
5307
+ try {
5308
+ const { records, latestTs } = await readTaskIpcLogsSnapshot(this.context, {
5309
+ source,
5310
+ resource,
5311
+ tail,
5312
+ afterTs
5313
+ });
5314
+ return {
5315
+ success: true,
5316
+ results: { records, latestTs, source, resource }
5317
+ };
5318
+ } catch (e) {
5319
+ return {
5320
+ success: false,
5321
+ results: { error: e?.message ?? String(e) }
5322
+ };
5323
+ }
5324
+ }
5325
+ };
5326
+
5327
+ // src/tasks/TasksRegistry.js
4868
5328
  var TasksRegistry = class _TasksRegistry {
4869
- map = {};
5329
+ /**
5330
+ * @param {Record<string, Function>} [initial] Optional seed entries to copy in.
5331
+ */
4870
5332
  constructor(initial) {
5333
+ this.map = {};
4871
5334
  if (initial) {
4872
5335
  this.addMany(initial);
4873
5336
  }
4874
5337
  }
5338
+ /**
5339
+ * Build a registry pre-populated with every core task plus legacy aliases.
5340
+ * Prefer this over `new TasksRegistry()` for anything that wants `ping`/`stop`/etc.
5341
+ *
5342
+ * @returns {TasksRegistry}
5343
+ */
4875
5344
  static withCoreTasks() {
4876
- return new _TasksRegistry().add("ping", TaskPing).add("sampleProcess", TaskSampleProcess).add("shellCommand", TaskShellCommand).add("systemInfo", TaskSystemInfo).add("taskSumAB", TaskSumAB).add("stopRunner", TaskStopRunner).add("stop", TaskStopRunner);
5345
+ return new _TasksRegistry().add("ping", TaskPing).add("sampleProcess", TaskSampleProcess).add("shellCommand", TaskShellCommand).add("systemInfo", TaskSystemInfo).add("info", TaskSystemInfo).add("taskSumAB", TaskSumAB).add("stopRunner", TaskStopRunner).add("stop", TaskStopRunner).add("getLogs", TaskGetLogs);
4877
5346
  }
5347
+ /**
5348
+ * Register a single task class under a name. Overwrites any previous entry.
5349
+ *
5350
+ * @param {string} taskName
5351
+ * @param {Function} taskClass Subclass of `AbstractTask`.
5352
+ * @returns {this}
5353
+ */
4878
5354
  add(taskName, taskClass) {
4879
5355
  this.map[taskName] = taskClass;
4880
5356
  return this;
4881
5357
  }
5358
+ /**
5359
+ * Bulk-register a name → class map. Later calls override earlier ones.
5360
+ *
5361
+ * @param {Record<string, Function>} entries
5362
+ * @returns {this}
5363
+ */
4882
5364
  addMany(entries) {
4883
5365
  for (const [name, klass] of Object.entries(entries)) {
4884
5366
  this.add(name, klass);
4885
5367
  }
4886
5368
  return this;
4887
5369
  }
5370
+ /**
5371
+ * Look up a task class by name. Returns `undefined` when the name is unknown;
5372
+ * the runner treats that as "some other worker may handle this" and skips.
5373
+ *
5374
+ * @param {string} taskName
5375
+ * @returns {Function | undefined}
5376
+ */
4888
5377
  get(taskName) {
4889
5378
  return this.map[taskName];
4890
5379
  }
5380
+ /**
5381
+ * Strict variant of {@link get}: throws {@link ParamError} (with the list
5382
+ * of supported names) when `taskName` is unknown. Use from enqueuer code
5383
+ * paths where an unknown name is a hard CLI/programmer error.
5384
+ *
5385
+ * @param {string} taskName
5386
+ * @returns {Function}
5387
+ */
5388
+ requireClass(taskName) {
5389
+ const TaskClass = taskName ? this.map[taskName] : void 0;
5390
+ if (!TaskClass) {
5391
+ const supported = this.listSupportedTasks().join(", ") || "(none)";
5392
+ throw new ParamError(
5393
+ `Unknown task "${taskName ?? ""}". Supported on this registry: ${supported}`
5394
+ );
5395
+ }
5396
+ return TaskClass;
5397
+ }
5398
+ /**
5399
+ * Dispatcher used by `send-task` / programmatic enqueuers: pick `name`
5400
+ * from `overrides` or `context.params`, look up the class, and delegate
5401
+ * to its static {@link AbstractTask.resolveParams} with `name` seeded into
5402
+ * the overrides. The returned object is shaped for {@link enqueueTask}.
5403
+ *
5404
+ * Validation failures (unknown task, missing required custom params, etc.)
5405
+ * surface as {@link ParamError} so the caller aborts cleanly before any
5406
+ * row is inserted.
5407
+ *
5408
+ * @param {object} context
5409
+ * @param {Record<string, unknown>} [overrides]
5410
+ * @returns {Promise<object>}
5411
+ */
5412
+ async resolveTaskParams(context, overrides = {}) {
5413
+ const overrideName = typeof overrides.name === "string" ? overrides.name.trim() : overrides.name;
5414
+ const fromCli = context.params.get("name", "string");
5415
+ const cliName = typeof fromCli === "string" ? fromCli.trim() : fromCli;
5416
+ const name = overrideName || cliName;
5417
+ if (!name) {
5418
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
5419
+ }
5420
+ const TaskClass = this.requireClass(name);
5421
+ return TaskClass.resolveParams(context, { ...overrides, name });
5422
+ }
5423
+ /**
5424
+ * Names of every registered task, sorted alphabetically (useful for CLI output
5425
+ * and allowlist sanity checks).
5426
+ *
5427
+ * @returns {string[]}
5428
+ */
4891
5429
  listSupportedTasks() {
4892
5430
  return Object.keys(this.map).sort();
4893
5431
  }
5432
+ /**
5433
+ * Shallow copy of the internal map, for handing to `addMany` on another registry
5434
+ * or for serialization.
5435
+ *
5436
+ * @returns {Record<string, Function>}
5437
+ */
4894
5438
  toObject() {
4895
5439
  return { ...this.map };
4896
5440
  }
4897
5441
  };
4898
5442
 
4899
- // src/tasks/taskScriptRunner.ts
5443
+ // src/tasks/serviceTaskAllowlist.js
5444
+ function normalizeAllowedTasks(value) {
5445
+ if (!value) return void 0;
5446
+ if (Array.isArray(value)) {
5447
+ const out2 = value.map((v) => String(v).trim()).filter(Boolean);
5448
+ return out2.length ? out2 : void 0;
5449
+ }
5450
+ const out = String(value).split(",").map((v) => v.trim()).filter(Boolean);
5451
+ return out.length ? out : void 0;
5452
+ }
5453
+
5454
+ // src/tasks/taskScriptRunner.js
4900
5455
  var import_node_child_process2 = require("child_process");
4901
5456
 
4902
- // src/tasks/index.ts
5457
+ // src/tasks/index.js
4903
5458
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
4904
5459
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
4905
5460
  function getDb3(context) {
@@ -4914,15 +5469,6 @@ function normalizeRegistry(registry) {
4914
5469
  if (registry instanceof TasksRegistry) return registry;
4915
5470
  return new TasksRegistry().addMany(registry);
4916
5471
  }
4917
- function normalizeAllowedTasks(value) {
4918
- if (!value) return void 0;
4919
- if (Array.isArray(value)) {
4920
- const out2 = value.map((v) => String(v).trim()).filter(Boolean);
4921
- return out2.length ? out2 : void 0;
4922
- }
4923
- const out = String(value).split(",").map((v) => v.trim()).filter(Boolean);
4924
- return out.length ? out : void 0;
4925
- }
4926
5472
  async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs) {
4927
5473
  context.logger.warn?.(`[tasks] signaling ${runningTaskInstances.size} running task(s) to stop`);
4928
5474
  for (const [, taskInstance] of runningTaskInstances) {
@@ -4938,18 +5484,20 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
4938
5484
  }
4939
5485
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
4940
5486
  const db = getDb3(context);
4941
- const taskName = row.task;
5487
+ const taskName = row.name;
4942
5488
  const TaskClass = registry.get(taskName);
4943
- const { paused_at: _pausedAt, ...rowForHistory } = row;
4944
5489
  if (!TaskClass) {
4945
5490
  const err = { message: `Unknown task "${taskName}"` };
4946
- await db(historyTable).insert({
4947
- ...rowForHistory,
4948
- completed_at: /* @__PURE__ */ new Date(),
4949
- success: false,
4950
- params: toJsonColumn(row.params),
4951
- results: toJsonColumn(err)
4952
- });
5491
+ await db(historyTable).insert(
5492
+ taskHistoryInsertFromQueueRow(row, {
5493
+ completed_at: /* @__PURE__ */ new Date(),
5494
+ success: false,
5495
+ status: "failed",
5496
+ status_changed_at: db.fn.now(),
5497
+ params: toJsonColumn(row.params),
5498
+ results: toJsonColumn(err)
5499
+ })
5500
+ );
4953
5501
  if (row.schedule) {
4954
5502
  await db(tasksTable).where({ id: row.id }).update({
4955
5503
  started_at: null,
@@ -4957,7 +5505,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4957
5505
  success: false,
4958
5506
  results: toJsonColumn(err),
4959
5507
  past_due: null,
4960
- paused_at: db.fn.now(),
5508
+ status: "paused",
5509
+ status_changed_at: db.fn.now(),
4961
5510
  progress: LOCKED_BY_ERROR_MESSAGE
4962
5511
  });
4963
5512
  } else {
@@ -4984,13 +5533,16 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4984
5533
  } finally {
4985
5534
  runningTaskInstances.delete(row.id);
4986
5535
  }
4987
- await db(historyTable).insert({
4988
- ...rowForHistory,
4989
- completed_at: /* @__PURE__ */ new Date(),
4990
- success,
4991
- params: toJsonColumn(row.params),
4992
- results: toJsonColumn(results)
4993
- });
5536
+ await db(historyTable).insert(
5537
+ taskHistoryInsertFromQueueRow(row, {
5538
+ completed_at: /* @__PURE__ */ new Date(),
5539
+ success,
5540
+ status: success ? "completed" : "failed",
5541
+ status_changed_at: db.fn.now(),
5542
+ params: toJsonColumn(row.params),
5543
+ results: toJsonColumn(results)
5544
+ })
5545
+ );
4994
5546
  if (!success) {
4995
5547
  const dbName = String(context?.params?.get?.("dbName") || "local");
4996
5548
  const tableName = String(context?.params?.get?.("table") || "tasks");
@@ -5005,19 +5557,32 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5005
5557
  const rerunCommand = results && typeof results === "object" && results.rerunCommand ? results.rerunCommand : fallbackRecoverCommand;
5006
5558
  appendTaskIpcLog(context, row, {
5007
5559
  level: "error",
5008
- message: `[tasks] task failed: ${row.task} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
5560
+ message: `[tasks] task failed: ${row.name} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
5009
5561
  details: results
5010
5562
  });
5011
5563
  }
5012
5564
  if (row.schedule) {
5013
5565
  if (success) {
5566
+ let nextRunAt = null;
5567
+ try {
5568
+ nextRunAt = nextTimeMatch(row.schedule, /* @__PURE__ */ new Date());
5569
+ } catch (e) {
5570
+ context.logger?.warn?.(`[tasks] nextTimeMatch after success for task ${row.id}: ${e?.message ?? String(e)}`);
5571
+ }
5014
5572
  await db(tasksTable).where({ id: row.id }).update({
5015
5573
  started_at: null,
5016
5574
  completed_at: /* @__PURE__ */ new Date(),
5017
5575
  success,
5018
5576
  results: toJsonColumn(results),
5019
5577
  progress: null,
5020
- past_due: null
5578
+ past_due: null,
5579
+ status: "idle",
5580
+ status_changed_at: db.fn.now(),
5581
+ next_run_at: nextRunAt,
5582
+ // Claim overwrites these; clear so idle rows stay “any worker” (see claimNextRunnableTask).
5583
+ service_name: null,
5584
+ server_name: null,
5585
+ instance_number: null
5021
5586
  });
5022
5587
  } else {
5023
5588
  await db(tasksTable).where({ id: row.id }).update({
@@ -5025,7 +5590,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5025
5590
  completed_at: /* @__PURE__ */ new Date(),
5026
5591
  success,
5027
5592
  results: toJsonColumn(results),
5028
- paused_at: db.fn.now(),
5593
+ status: "paused",
5594
+ status_changed_at: db.fn.now(),
5029
5595
  progress: LOCKED_BY_ERROR_MESSAGE,
5030
5596
  past_due: null
5031
5597
  });
@@ -5037,58 +5603,93 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5037
5603
  const stopAllowanceMs = stopRunnerRequested ? Number(results.allowanceMs ?? 5e3) : 0;
5038
5604
  return { stopRunnerRequested, stopAllowanceMs };
5039
5605
  }
5040
- async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
5606
+ function shuffleTaskRowsInPlace(rows) {
5607
+ for (let i = rows.length - 1; i > 0; i--) {
5608
+ const j = Math.floor(Math.random() * (i + 1));
5609
+ const t = rows[i];
5610
+ rows[i] = rows[j];
5611
+ rows[j] = t;
5612
+ }
5613
+ }
5614
+ async function claimNextRunnableTask(context, tasksTable, serviceGroup, registry, scanLimit, taskNames, runnerIdentity) {
5041
5615
  const db = getDb3(context);
5042
- let query = db(tasksTable).whereNull("started_at").whereNull("paused_at").where({ target }).orderByRaw("CASE WHEN past_due IS NULL THEN 1 ELSE 0 END ASC").orderBy([{ column: "priority", order: "desc" }]).orderByRaw("CASE WHEN completed_at IS NULL THEN 0 ELSE 1 END ASC").orderBy([{ column: "completed_at", order: "asc" }, { column: "created_at", order: "asc" }]).limit(scanLimit);
5616
+ let query = db(tasksTable).where({ status: "idle" }).where(function() {
5617
+ this.whereNull("service_group").orWhere({ service_group: serviceGroup });
5618
+ }).orderByRaw("CASE WHEN past_due IS NULL THEN 1 ELSE 0 END ASC").orderBy([{ column: "priority", order: "asc" }]).orderByRaw("CASE WHEN completed_at IS NULL THEN 0 ELSE 1 END ASC").orderBy([{ column: "completed_at", order: "asc" }, { column: "created_at", order: "asc" }]).limit(scanLimit);
5043
5619
  if (taskNames && taskNames.length > 0) {
5044
- query = query.whereIn("task", taskNames);
5620
+ query = query.whereIn("name", taskNames);
5621
+ }
5622
+ if (runnerIdentity) {
5623
+ query = query.where(function() {
5624
+ this.whereNull("service_name").orWhere({ service_name: runnerIdentity.service_name });
5625
+ }).where(function() {
5626
+ this.whereNull("instance_number").orWhere({ instance_number: runnerIdentity.instance_number });
5627
+ }).where(function() {
5628
+ this.whereNull("server_name").orWhere({ server_name: runnerIdentity.server_name });
5629
+ });
5630
+ } else {
5631
+ query = query.whereNull("service_name").whereNull("instance_number").whereNull("server_name");
5045
5632
  }
5046
5633
  const candidates = await query;
5634
+ shuffleTaskRowsInPlace(candidates);
5047
5635
  for (const row of candidates) {
5048
5636
  if (!row.past_due && row.schedule && !timeMatcher(row.schedule)) {
5049
5637
  continue;
5050
5638
  }
5051
- const TaskClass = registry.get(row.task);
5052
- if (TaskClass) {
5053
- const taskInstance = new TaskClass(context, row);
5054
- const reason = taskInstance.cantRunReason ? await taskInstance.cantRunReason() : null;
5055
- if (reason) {
5056
- if (!row.past_due) {
5057
- await db(tasksTable).where({ id: row.id }).update({
5058
- past_due: db.fn.now(),
5059
- progress: String(reason)
5060
- });
5061
- }
5062
- continue;
5639
+ const TaskClass = registry.get(row.name);
5640
+ if (!TaskClass) {
5641
+ continue;
5642
+ }
5643
+ const taskInstance = new TaskClass(context, row);
5644
+ const reason = taskInstance.cantRunReason ? await taskInstance.cantRunReason() : null;
5645
+ if (reason) {
5646
+ if (!row.past_due) {
5647
+ await db(tasksTable).where({ id: row.id }).update({
5648
+ past_due: db.fn.now(),
5649
+ progress: String(reason)
5650
+ });
5063
5651
  }
5652
+ continue;
5653
+ }
5654
+ const claimPatch = {
5655
+ started_at: db.fn.now(),
5656
+ status: "running",
5657
+ status_changed_at: db.fn.now()
5658
+ };
5659
+ if (runnerIdentity) {
5660
+ claimPatch.service_name = runnerIdentity.service_name;
5661
+ claimPatch.server_name = runnerIdentity.server_name;
5662
+ claimPatch.instance_number = runnerIdentity.instance_number;
5064
5663
  }
5065
- const updated = await db(tasksTable).where({ id: row.id }).whereNull("started_at").whereNull("paused_at").update({ started_at: db.fn.now() }).returning("*");
5664
+ const updated = await db(tasksTable).where({ id: row.id, status: "idle" }).update(claimPatch).returning("*");
5066
5665
  const claimed = Array.isArray(updated) ? updated[0] : null;
5067
5666
  if (claimed) return claimed;
5068
5667
  }
5069
5668
  return null;
5070
5669
  }
5071
5670
  async function runTasksLoop(context, options) {
5072
- const queue = options.queue ?? "tasks";
5671
+ const queueName = options.queueName ?? "tasks";
5073
5672
  const target = options.target;
5074
5673
  const pollMs = options.pollMs ?? 1e3;
5075
- const maxParallel = options.maxParallel ?? 1;
5674
+ const claimJitterMs = options.claimJitterMs ?? 0;
5675
+ const maxParallel = options.maxParallel ?? 32;
5076
5676
  const scanLimit = options.scanLimit ?? 100;
5077
5677
  const allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5078
5678
  const registry = normalizeRegistry(options.registry);
5079
- const { tasksTable, historyTable } = queueToTableNames(queue);
5679
+ const { tasksTable, historyTable } = queueToTableNames(queueName);
5080
5680
  if (!target) throw new Error("runTasksLoop: target is required");
5681
+ context.tasksQueueName = queueName;
5081
5682
  const runningPromises = /* @__PURE__ */ new Set();
5082
5683
  const runningTaskInstances = /* @__PURE__ */ new Map();
5083
5684
  let runningStopControlPromise = null;
5084
5685
  let stopRequested = false;
5085
5686
  let stopAllowanceMs = 5e3;
5086
- context.__tasksRunnerStop = false;
5687
+ context.tasksRunnerStop = false;
5087
5688
  let registryReg = null;
5088
5689
  let registryInterval = null;
5690
+ let runnerIdentity = null;
5089
5691
  const hbGroup = options.runnerServiceGroup?.trim();
5090
5692
  if (hbGroup) {
5091
- const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
5092
5693
  const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
5093
5694
  const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
5094
5695
  const defaultMeta = {
@@ -5096,16 +5697,21 @@ async function runTasksLoop(context, options) {
5096
5697
  allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
5097
5698
  };
5098
5699
  registryReg = await registerInServicesRegistry(context, {
5099
- queue,
5700
+ queueName,
5100
5701
  target,
5101
5702
  serviceGroup: hbGroup,
5102
5703
  serviceName: options.runnerServiceName,
5103
- identityDir,
5704
+ instanceNumber: options.runnerInstanceNumber,
5104
5705
  staleMs,
5105
5706
  groupMaxInstances: options.runnerGroupMaxInstances,
5106
5707
  enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
5107
5708
  metadata: options.runnerMetadata ?? defaultMeta
5108
5709
  });
5710
+ runnerIdentity = {
5711
+ service_name: registryReg.serviceName,
5712
+ server_name: import_node_os3.default.hostname(),
5713
+ instance_number: registryReg.instanceNumber
5714
+ };
5109
5715
  registryInterval = setInterval(() => {
5110
5716
  void touchServicesRegistry(context, registryReg).catch((err) => {
5111
5717
  context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
@@ -5113,7 +5719,7 @@ async function runTasksLoop(context, options) {
5113
5719
  }, hbIntervalMs);
5114
5720
  }
5115
5721
  try {
5116
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
5722
+ while (!context.isStop() && !stopRequested && context.tasksRunnerStop !== true) {
5117
5723
  if (!runningStopControlPromise) {
5118
5724
  const claimedStopTask = await claimNextRunnableTask(
5119
5725
  context,
@@ -5121,7 +5727,8 @@ async function runTasksLoop(context, options) {
5121
5727
  target,
5122
5728
  registry,
5123
5729
  10,
5124
- ["stopRunner", "stop"]
5730
+ ["stopRunner", "stop"],
5731
+ runnerIdentity
5125
5732
  );
5126
5733
  if (claimedStopTask) {
5127
5734
  runningStopControlPromise = executeClaimedTask(
@@ -5135,7 +5742,7 @@ async function runTasksLoop(context, options) {
5135
5742
  if (outcome.stopRunnerRequested && !stopRequested) {
5136
5743
  stopRequested = true;
5137
5744
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5138
- context.__tasksRunnerStop = true;
5745
+ context.tasksRunnerStop = true;
5139
5746
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5140
5747
  }
5141
5748
  }).finally(() => {
@@ -5143,6 +5750,9 @@ async function runTasksLoop(context, options) {
5143
5750
  });
5144
5751
  }
5145
5752
  }
5753
+ if (claimJitterMs > 0) {
5754
+ await sleepMs(Math.floor(Math.random() * (claimJitterMs + 1)));
5755
+ }
5146
5756
  while (runningPromises.size < maxParallel) {
5147
5757
  const claimed = await claimNextRunnableTask(
5148
5758
  context,
@@ -5150,14 +5760,15 @@ async function runTasksLoop(context, options) {
5150
5760
  target,
5151
5761
  registry,
5152
5762
  scanLimit,
5153
- allowedTasks
5763
+ allowedTasks,
5764
+ runnerIdentity
5154
5765
  );
5155
5766
  if (!claimed) break;
5156
5767
  const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
5157
5768
  if (outcome.stopRunnerRequested && !stopRequested) {
5158
5769
  stopRequested = true;
5159
5770
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5160
- context.__tasksRunnerStop = true;
5771
+ context.tasksRunnerStop = true;
5161
5772
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5162
5773
  }
5163
5774
  }).finally(() => {
@@ -5165,7 +5776,16 @@ async function runTasksLoop(context, options) {
5165
5776
  });
5166
5777
  runningPromises.add(p);
5167
5778
  }
5168
- await sleepMs(pollMs);
5779
+ const wakePromises = [...runningPromises];
5780
+ if (runningStopControlPromise) {
5781
+ wakePromises.push(runningStopControlPromise);
5782
+ }
5783
+ if (wakePromises.length === 0) {
5784
+ await sleepMs(pollMs);
5785
+ } else {
5786
+ const safe = wakePromises.map((p) => p.catch(() => void 0));
5787
+ await Promise.race([sleepMs(pollMs), Promise.race(safe)]);
5788
+ }
5169
5789
  }
5170
5790
  if (context.isStop() && !stopRequested) {
5171
5791
  await signalRunningTasksStop(context, runningTaskInstances, 5e3);
@@ -5200,71 +5820,88 @@ async function runTasksLoop(context, options) {
5200
5820
  }
5201
5821
  }
5202
5822
  var TasksManager = class _TasksManager {
5203
- context;
5204
- queue;
5205
- target;
5206
- recreateTaskTables;
5207
- pollMs;
5208
- maxParallel;
5209
- scanLimit;
5210
- allowedTasks;
5211
- registry;
5212
- runnerServiceGroup;
5213
- runnerServiceName;
5214
- runnerIdentityDir;
5215
- runnerHeartbeatIntervalMs;
5216
- runnerHeartbeatStaleMs;
5217
- runnerGroupMaxInstances;
5218
- runnerEnforceMaxInstances;
5219
- runnerMetadata;
5823
+ /**
5824
+ * @param {object} context
5825
+ * @param {{
5826
+ * queueName?: string,
5827
+ * target?: string,
5828
+ * recreateTaskTables?: boolean,
5829
+ * pollMs?: number,
5830
+ * claimJitterMs?: number,
5831
+ * maxParallel?: number,
5832
+ * scanLimit?: number,
5833
+ * allowedTasks?: string | string[],
5834
+ * registry?: TasksRegistry | Record<string, Function>,
5835
+ * runnerServiceGroup?: string,
5836
+ * runnerServiceName?: string,
5837
+ * runnerInstanceNumber?: number,
5838
+ * runnerHeartbeatIntervalMs?: number,
5839
+ * runnerHeartbeatStaleMs?: number,
5840
+ * runnerGroupMaxInstances?: number,
5841
+ * runnerEnforceMaxInstances?: boolean,
5842
+ * runnerMetadata?: Record<string, unknown>,
5843
+ * }} [options]
5844
+ */
5220
5845
  constructor(context, options = {}) {
5221
5846
  this.context = context;
5222
- this.queue = options.queue ?? "tasks";
5847
+ this.queueName = options.queueName ?? "tasks";
5223
5848
  this.target = options.target ?? "localRunner";
5224
5849
  this.recreateTaskTables = options.recreateTaskTables ?? false;
5225
5850
  this.pollMs = options.pollMs ?? 1e3;
5851
+ this.claimJitterMs = options.claimJitterMs ?? 0;
5226
5852
  this.maxParallel = options.maxParallel ?? 1;
5227
5853
  this.scanLimit = options.scanLimit ?? 100;
5228
5854
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5229
5855
  this.registry = normalizeRegistry(options.registry);
5230
5856
  this.runnerServiceGroup = options.runnerServiceGroup;
5231
5857
  this.runnerServiceName = options.runnerServiceName;
5232
- this.runnerIdentityDir = options.runnerIdentityDir;
5858
+ this.runnerInstanceNumber = options.runnerInstanceNumber;
5233
5859
  this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
5234
5860
  this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
5235
5861
  this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
5236
5862
  this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
5237
5863
  this.runnerMetadata = options.runnerMetadata;
5238
5864
  }
5865
+ /**
5866
+ * Preferred factory: reads defaults from `context.params` (module namespace
5867
+ * `"tasks"`), then overlays explicit `options`. Keeps CLI flags, env vars,
5868
+ * and inline options in one consistent resolver.
5869
+ *
5870
+ * @param {object} context
5871
+ * @param {ConstructorParameters<typeof TasksManager>[1]} [options]
5872
+ * @returns {TasksManager}
5873
+ */
5239
5874
  static init(context, options = {}) {
5240
5875
  const defs2 = {
5241
5876
  table: "string default tasks",
5242
5877
  target: "string default localRunner",
5243
5878
  recreateTaskTables: "boolean default false",
5244
5879
  pollMs: "number default 1000",
5880
+ claimJitterMs: "number default 0",
5245
5881
  maxParallel: "number default 1",
5246
5882
  scanLimit: "number default 100",
5247
5883
  allowedTasks: "string",
5248
5884
  runnerServiceGroup: "string",
5249
5885
  runnerServiceName: "string",
5250
- runnerIdentityDir: "string default ./data/runner-identities",
5886
+ runnerInstanceNumber: "number",
5251
5887
  runnerHeartbeatIntervalMs: "number default 10000",
5252
5888
  runnerHeartbeatStaleMs: "number default 45000",
5253
5889
  runnerGroupMaxInstances: "number",
5254
5890
  runnerEnforceMaxInstances: "boolean default true"
5255
5891
  };
5256
- const discovered = context.params.getAllForModule(defs2);
5892
+ const discovered = context.params.getAllForModule("tasks", defs2);
5257
5893
  const resolved = {
5258
- queue: discovered.table,
5894
+ queueName: discovered.table,
5259
5895
  target: discovered.target,
5260
5896
  recreateTaskTables: discovered.recreateTaskTables,
5261
5897
  pollMs: discovered.pollMs,
5898
+ claimJitterMs: discovered.claimJitterMs,
5262
5899
  maxParallel: discovered.maxParallel,
5263
5900
  scanLimit: discovered.scanLimit,
5264
5901
  allowedTasks: discovered.allowedTasks,
5265
5902
  runnerServiceGroup: discovered.runnerServiceGroup,
5266
5903
  runnerServiceName: discovered.runnerServiceName,
5267
- runnerIdentityDir: discovered.runnerIdentityDir,
5904
+ runnerInstanceNumber: discovered.runnerInstanceNumber,
5268
5905
  runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
5269
5906
  runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
5270
5907
  runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
@@ -5273,24 +5910,39 @@ var TasksManager = class _TasksManager {
5273
5910
  };
5274
5911
  return new _TasksManager(context, resolved);
5275
5912
  }
5913
+ /**
5914
+ * Idempotently ensure the three backing tables exist for this queue.
5915
+ *
5916
+ * @param {{ recreate?: boolean }} [options]
5917
+ * @returns {Promise<void>}
5918
+ */
5276
5919
  async ensureTaskTables(options = {}) {
5277
5920
  await ensureTaskTables(this.context, {
5278
- queue: this.queue,
5921
+ queueName: this.queueName,
5279
5922
  recreate: options.recreate ?? this.recreateTaskTables
5280
5923
  });
5281
5924
  }
5925
+ /**
5926
+ * Start the runner loop using this manager's resolved config. Per-call
5927
+ * options override the stored defaults, but `runnerMetadata` still falls
5928
+ * through when omitted.
5929
+ *
5930
+ * @param {Partial<ConstructorParameters<typeof TasksManager>[1]>} [options]
5931
+ * @returns {Promise<void>}
5932
+ */
5282
5933
  async runTasksLoop(options = {}) {
5283
5934
  await runTasksLoop(this.context, {
5284
- queue: options.queue ?? this.queue,
5935
+ queueName: options.queueName ?? this.queueName,
5285
5936
  target: options.target ?? this.target,
5286
5937
  pollMs: options.pollMs ?? this.pollMs,
5938
+ claimJitterMs: options.claimJitterMs ?? this.claimJitterMs,
5287
5939
  maxParallel: options.maxParallel ?? this.maxParallel,
5288
5940
  scanLimit: options.scanLimit ?? this.scanLimit,
5289
5941
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
5290
5942
  registry: options.registry ?? this.registry,
5291
5943
  runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
5292
5944
  runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
5293
- runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
5945
+ runnerInstanceNumber: options.runnerInstanceNumber ?? this.runnerInstanceNumber,
5294
5946
  runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
5295
5947
  runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
5296
5948
  runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
@@ -5300,9 +5952,8 @@ var TasksManager = class _TasksManager {
5300
5952
  }
5301
5953
  };
5302
5954
 
5303
- // src/scripts/cli-runner.ts
5955
+ // src/scripts/cli-runner.js
5304
5956
  var defs = {
5305
- dbName: "string default local",
5306
5957
  tasksModule: "string"
5307
5958
  };
5308
5959
  async function loadTasksModule(modulePath) {
@@ -5314,11 +5965,8 @@ async function loadTasksModule(modulePath) {
5314
5965
  return imported.tasksRegistry;
5315
5966
  }
5316
5967
  var flow = async (context) => {
5317
- const {
5318
- dbName,
5319
- tasksModule
5320
- } = context.params.getAll(defs);
5321
- const db = await dbInit(context, dbName);
5968
+ const { tasksModule } = context.params.getAll(defs);
5969
+ const db = await Db.init(context);
5322
5970
  context.db = db;
5323
5971
  const registry = new TasksRegistry().addMany(defaultTasksRegistry.toObject());
5324
5972
  if (tasksModule) {