@nmakarov/cli-toolkit 0.21.0 → 0.25.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 +1370 -623
  6. package/dist/cli-runner.cjs.map +1 -1
  7. package/dist/cli-runner.js +1415 -667
  8. package/dist/cli-runner.js.map +1 -1
  9. package/dist/db.cjs +173 -158
  10. package/dist/db.cjs.map +1 -1
  11. package/dist/db.js +172 -151
  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 +1831 -713
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +1837 -713
  32. package/dist/index.js.map +1 -1
  33. package/dist/init.cjs +93 -68
  34. package/dist/init.cjs.map +1 -1
  35. package/dist/init.js +108 -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 +18 -9
  46. package/dist/params.cjs.map +1 -1
  47. package/dist/params.js +18 -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
@@ -15,7 +15,7 @@ var __export = (target, all) => {
15
15
  __defProp(target, name, { get: all[name], enumerable: true });
16
16
  };
17
17
 
18
- // src/screen/components.ts
18
+ // src/screen/components.js
19
19
  import { createElement as h } from "react";
20
20
  import { Box, Text } from "ink";
21
21
  function getScreenWidth(maxWidth = null) {
@@ -90,13 +90,12 @@ function ScreenFooter({ lines, textStyle }) {
90
90
  );
91
91
  }
92
92
  var init_components = __esm({
93
- "src/screen/components.ts"() {
94
- "use strict";
93
+ "src/screen/components.js"() {
95
94
  }
96
95
  });
97
96
 
98
- // src/screen/list-components.ts
99
- import React2, { useState, useEffect, useRef, createElement } from "react";
97
+ // src/screen/list-components.js
98
+ import React, { useState, useEffect, useRef, createElement } from "react";
100
99
  import { Box as Box2, Text as Text2 } from "ink";
101
100
  function MultiColumnListComponent({ items, ctx, selectedIndexRef }) {
102
101
  const [, forceUpdate] = useState({});
@@ -232,11 +231,11 @@ function MultiColumnListWithPreviewComponent({
232
231
  const previewRows = [];
233
232
  if (typeof previewContent === "string") {
234
233
  previewRows.push(h2(ScreenRow, { key: "preview-string", children: h2(Text2, { bold: true }, previewContent) }));
235
- } else if (typeof previewContent === "object" && !React2.isValidElement(previewContent) && previewContent !== null) {
234
+ } else if (typeof previewContent === "object" && !React.isValidElement(previewContent) && previewContent !== null) {
236
235
  Object.entries(previewContent).forEach(([key, value], idx) => {
237
236
  previewRows.push(h2(ScreenRow, { key: `preview-${key}-${idx}`, children: h2(Text2, {}, `${key}: ${value}`) }));
238
237
  });
239
- } else if (React2.isValidElement(previewContent)) {
238
+ } else if (React.isValidElement(previewContent)) {
240
239
  previewRows.push(h2(ScreenRow, { key: "preview-element", children: previewContent }));
241
240
  }
242
241
  return h2(
@@ -249,11 +248,13 @@ function MultiColumnListWithPreviewComponent({
249
248
  ...previewRows
250
249
  );
251
250
  }
252
- function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sortable = false, maxHeight, sortHighlightStyle, selectionMarker = " " }) {
251
+ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sortable = false, maxHeight, sortHighlightStyle, selectionMarker = " ", onSelectionChange }) {
253
252
  const [, forceUpdate] = useState({});
254
253
  const [sortOrder, setSortOrder] = useState("none");
255
254
  const [scrollOffset, setScrollOffset] = useState(0);
256
255
  const scrollStateRef = useRef({ scrollOffset: 0, maxHeight: 0, totalItems: 0 });
256
+ const itemsRef = useRef(items);
257
+ itemsRef.current = items;
257
258
  const defaultGetTitle = (item) => {
258
259
  return getTitle ? getTitle(item) : typeof item.value === "string" ? item.value : item.value?.title || item.name;
259
260
  };
@@ -267,18 +268,24 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
267
268
  return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
268
269
  }
269
270
  }) : items;
271
+ const displayItemsRef = useRef(displayItems);
272
+ displayItemsRef.current = displayItems;
270
273
  const effectiveMaxHeight = maxHeight || displayItems.length;
271
- const canScroll = displayItems.length > effectiveMaxHeight;
274
+ const _canScroll = displayItems.length > effectiveMaxHeight;
272
275
  const maxScrollOffset = Math.max(0, displayItems.length - effectiveMaxHeight);
273
276
  const clampedScrollOffset = Math.min(Math.max(0, scrollOffset), maxScrollOffset);
274
277
  const visibleItems = displayItems.slice(clampedScrollOffset, clampedScrollOffset + effectiveMaxHeight);
275
278
  const canScrollUp = clampedScrollOffset > 0;
276
279
  const canScrollDown = clampedScrollOffset < maxScrollOffset;
277
280
  scrollStateRef.current = { scrollOffset, maxHeight: effectiveMaxHeight, totalItems: displayItems.length };
281
+ const onSelectionChangeRef = useRef(onSelectionChange);
282
+ onSelectionChangeRef.current = onSelectionChange;
278
283
  useEffect(() => {
279
284
  ctx.setAction("moveUp", () => {
280
285
  const newIndex = Math.max(0, selectedIndexRef.current - 1);
281
286
  selectedIndexRef.current = newIndex;
287
+ const list = displayItemsRef.current;
288
+ onSelectionChangeRef.current?.(newIndex, list[newIndex]);
282
289
  const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
283
290
  const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
284
291
  const currentClampedScrollOffset = Math.min(Math.max(0, currentScrollOffset), currentMaxScrollOffset);
@@ -288,18 +295,11 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
288
295
  forceUpdate({});
289
296
  });
290
297
  ctx.setAction("moveDown", () => {
291
- const currentItems = sortable && sortOrder !== "none" ? [...items].sort((a, b) => {
292
- const titleA = titleGetter(a).toLowerCase();
293
- const titleB = titleGetter(b).toLowerCase();
294
- if (sortOrder === "asc") {
295
- return titleA < titleB ? -1 : titleA > titleB ? 1 : 0;
296
- } else {
297
- return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
298
- }
299
- }) : items;
300
- const maxIndex = currentItems.length - 1;
298
+ const currentItems = displayItemsRef.current;
299
+ const maxIndex = Math.max(0, currentItems.length - 1);
301
300
  const newIndex = Math.min(maxIndex, selectedIndexRef.current + 1);
302
301
  selectedIndexRef.current = newIndex;
302
+ onSelectionChangeRef.current?.(newIndex, currentItems[newIndex]);
303
303
  const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
304
304
  const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
305
305
  const currentClampedScrollOffset = Math.min(Math.max(0, currentScrollOffset), currentMaxScrollOffset);
@@ -309,8 +309,7 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
309
309
  forceUpdate({});
310
310
  });
311
311
  ctx.setAction("scrollUp", () => {
312
- const { scrollOffset: currentScrollOffset, maxHeight: currentMaxHeight, totalItems } = scrollStateRef.current;
313
- const currentMaxScrollOffset = Math.max(0, totalItems - currentMaxHeight);
312
+ const { scrollOffset: currentScrollOffset } = scrollStateRef.current;
314
313
  const newScrollOffset = Math.max(0, currentScrollOffset - 1);
315
314
  setScrollOffset(newScrollOffset);
316
315
  forceUpdate({});
@@ -325,9 +324,9 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
325
324
  if (sortable) {
326
325
  ctx.setAction("toggleSort", () => {
327
326
  const nextSort = sortOrder === "none" ? "asc" : sortOrder === "asc" ? "desc" : "none";
328
- const currentSelectedItem = displayItems[selectedIndexRef.current];
327
+ const currentSelectedItem = displayItemsRef.current[selectedIndexRef.current];
329
328
  setSortOrder(nextSort);
330
- const newSortedItems = nextSort !== "none" ? [...items].sort((a, b) => {
329
+ const newSortedItems = nextSort !== "none" ? [...itemsRef.current].sort((a, b) => {
331
330
  const titleA = titleGetter(a).toLowerCase();
332
331
  const titleB = titleGetter(b).toLowerCase();
333
332
  if (nextSort === "asc") {
@@ -335,7 +334,7 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
335
334
  } else {
336
335
  return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
337
336
  }
338
- }) : items;
337
+ }) : itemsRef.current;
339
338
  const newIndex = newSortedItems.findIndex((item) => item === currentSelectedItem);
340
339
  if (newIndex !== -1) {
341
340
  selectedIndexRef.current = newIndex;
@@ -473,14 +472,13 @@ function ListComponent({ items, ctx, selectedIndexRef, renderItem, getTitle, sor
473
472
  }
474
473
  var h2;
475
474
  var init_list_components = __esm({
476
- "src/screen/list-components.ts"() {
477
- "use strict";
475
+ "src/screen/list-components.js"() {
478
476
  init_components();
479
477
  h2 = createElement;
480
478
  }
481
479
  });
482
480
 
483
- // src/screen/screens.ts
481
+ // src/screen/screens.js
484
482
  import { useState as useState2, createElement as h3 } from "react";
485
483
  import { render, useInput, Text as Text3 } from "ink";
486
484
  function groupKeyBindings(bindings) {
@@ -558,7 +556,7 @@ async function showScreen(config2) {
558
556
  let renderResult = null;
559
557
  let initialized = false;
560
558
  const Screen = () => {
561
- const [updateCounter, setUpdateCounter] = useState2(0);
559
+ const [, setUpdateCounter] = useState2(0);
562
560
  if (!initialized) {
563
561
  const defaultBindings = [
564
562
  { key: "escape", caption: "go back", action: "back", protected: true, order: 1 },
@@ -677,7 +675,7 @@ async function showScreen(config2) {
677
675
  }
678
676
  }
679
677
  if (matchedBinding && actions[matchedBinding.action]) {
680
- const actionResult = actions[matchedBinding.action]({
678
+ actions[matchedBinding.action]({
681
679
  input,
682
680
  key,
683
681
  binding: matchedBinding
@@ -807,8 +805,7 @@ async function showMultiColumnListWithPreviewScreen(config2) {
807
805
  }
808
806
  var showMenuScreen, showWordGridScreen;
809
807
  var init_screens = __esm({
810
- "src/screen/screens.ts"() {
811
- "use strict";
808
+ "src/screen/screens.js"() {
812
809
  init_components();
813
810
  init_list_components();
814
811
  showMenuScreen = showListScreen;
@@ -816,9 +813,9 @@ var init_screens = __esm({
816
813
  }
817
814
  });
818
815
 
819
- // src/screen/ui-elements.ts
816
+ // src/screen/ui-elements.js
820
817
  import { createElement as h4 } from "react";
821
- import { Box as Box4, Text as Text4 } from "ink";
818
+ import { Box as Box3, Text as Text4 } from "ink";
822
819
  function ListItem({
823
820
  children,
824
821
  isSelected = false,
@@ -828,7 +825,7 @@ function ListItem({
828
825
  dimColor = false
829
826
  }) {
830
827
  return h4(
831
- Box4,
828
+ Box3,
832
829
  {},
833
830
  h4(Text4, {
834
831
  color: isSelected ? backgroundColor || "green" : color,
@@ -843,10 +840,10 @@ function TextBlock({
843
840
  color = "white",
844
841
  dimmed = false,
845
842
  bold = false,
846
- maxWidth
843
+ maxWidth: _maxWidth
847
844
  }) {
848
845
  return h4(
849
- Box4,
846
+ Box3,
850
847
  {},
851
848
  h4(Text4, {
852
849
  color,
@@ -857,7 +854,7 @@ function TextBlock({
857
854
  }
858
855
  function Divider({ character = "\u2500", width = 80 }) {
859
856
  return h4(
860
- Box4,
857
+ Box3,
861
858
  { marginY: 1 },
862
859
  h4(Text4, { dimColor: true }, character.repeat(width))
863
860
  );
@@ -872,7 +869,7 @@ function GridCell({
872
869
  align = "left"
873
870
  }) {
874
871
  return h4(
875
- Box4,
872
+ Box3,
876
873
  { width },
877
874
  h4(Text4, {
878
875
  color,
@@ -883,25 +880,24 @@ function GridCell({
883
880
  }, children)
884
881
  );
885
882
  }
886
- function InputField({ prompt, value, onChange, onSubmit }) {
883
+ function InputField({ prompt, value, onChange: _onChange, onSubmit: _onSubmit }) {
887
884
  return h4(
888
- Box4,
885
+ Box3,
889
886
  { flexDirection: "column" },
890
887
  h4(Text4, {}, prompt),
891
888
  h4(
892
- Box4,
889
+ Box3,
893
890
  { marginTop: 1 },
894
891
  h4(Text4, { color: "cyan" }, " > ", value, "_")
895
892
  )
896
893
  );
897
894
  }
898
895
  var init_ui_elements = __esm({
899
- "src/screen/ui-elements.ts"() {
900
- "use strict";
896
+ "src/screen/ui-elements.js"() {
901
897
  }
902
898
  });
903
899
 
904
- // src/screen/utils.ts
900
+ // src/screen/utils.js
905
901
  function buildBreadcrumb(parts) {
906
902
  if (parts.length === 0) return "";
907
903
  if (parts.length === 1) return parts[0];
@@ -915,12 +911,11 @@ function buildDetailBreadcrumb(path6, suffix = "") {
915
911
  return suffix ? `${breadcrumb} ${suffix}` : breadcrumb;
916
912
  }
917
913
  var init_utils = __esm({
918
- "src/screen/utils.ts"() {
919
- "use strict";
914
+ "src/screen/utils.js"() {
920
915
  }
921
916
  });
922
917
 
923
- // src/screen/footer-builder.ts
918
+ // src/screen/footer-builder.js
924
919
  function buildFooter(config2 = {}) {
925
920
  const {
926
921
  navigation = null,
@@ -971,8 +966,7 @@ function organizeFooterMessages(messages) {
971
966
  }
972
967
  var FooterPresets;
973
968
  var init_footer_builder = __esm({
974
- "src/screen/footer-builder.ts"() {
975
- "use strict";
969
+ "src/screen/footer-builder.js"() {
976
970
  FooterPresets = {
977
971
  /**
978
972
  * Menu screen footer
@@ -1031,10 +1025,10 @@ var init_footer_builder = __esm({
1031
1025
  }
1032
1026
  });
1033
1027
 
1034
- // src/screen/index.ts
1028
+ // src/screen/index.js
1035
1029
  var screen_exports = {};
1036
1030
  __export(screen_exports, {
1037
- Box: () => Box5,
1031
+ Box: () => Box4,
1038
1032
  Divider: () => Divider,
1039
1033
  FooterPresets: () => FooterPresets,
1040
1034
  GridCell: () => GridCell,
@@ -1043,7 +1037,7 @@ __export(screen_exports, {
1043
1037
  ListItem: () => ListItem,
1044
1038
  MultiColumnListComponent: () => MultiColumnListComponent,
1045
1039
  MultiColumnListWithPreviewComponent: () => MultiColumnListWithPreviewComponent,
1046
- React: () => React5,
1040
+ React: () => React2,
1047
1041
  ScreenBody: () => ScreenBody,
1048
1042
  ScreenContainer: () => ScreenContainer,
1049
1043
  ScreenDivider: () => ScreenDivider,
@@ -1057,6 +1051,7 @@ __export(screen_exports, {
1057
1051
  buildFooter: () => buildFooter,
1058
1052
  h: () => createElement2,
1059
1053
  load: () => load,
1054
+ memo: () => memo,
1060
1055
  organizeFooterMessages: () => organizeFooterMessages,
1061
1056
  showListScreen: () => showListScreen,
1062
1057
  showMenuScreen: () => showMenuScreen,
@@ -1065,14 +1060,15 @@ __export(screen_exports, {
1065
1060
  showScreen: () => showScreen,
1066
1061
  showWordGridScreen: () => showWordGridScreen,
1067
1062
  useCallback: () => useCallback,
1068
- useEffect: () => useEffect3,
1063
+ useEffect: () => useEffect2,
1069
1064
  useInput: () => useInput2,
1065
+ useLayoutEffect: () => useLayoutEffect,
1070
1066
  useMemo: () => useMemo,
1071
- useRef: () => useRef3,
1067
+ useRef: () => useRef2,
1072
1068
  useState: () => useState3
1073
1069
  });
1074
- import React5, { useState as useState3, useEffect as useEffect3, useRef as useRef3, useMemo, useCallback, createElement as createElement2 } from "react";
1075
- import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
1070
+ import React2, { useState as useState3, useEffect as useEffect2, useLayoutEffect, useRef as useRef2, useMemo, useCallback, memo, createElement as createElement2 } from "react";
1071
+ import { Box as Box4, Text as Text5, useInput as useInput2 } from "ink";
1076
1072
  async function load() {
1077
1073
  if (loadPromise) return loadPromise;
1078
1074
  loadPromise = Promise.all([
@@ -1084,8 +1080,7 @@ async function load() {
1084
1080
  }
1085
1081
  var loadPromise;
1086
1082
  var init_screen = __esm({
1087
- "src/screen/index.ts"() {
1088
- "use strict";
1083
+ "src/screen/index.js"() {
1089
1084
  init_screens();
1090
1085
  init_list_components();
1091
1086
  init_components();
@@ -1100,11 +1095,11 @@ var init_screen = __esm({
1100
1095
  }
1101
1096
  });
1102
1097
 
1103
- // src/scripts/cli-runner.ts
1098
+ // src/scripts/cli-runner.js
1104
1099
  import path5 from "path";
1105
1100
  import { pathToFileURL } from "url";
1106
1101
 
1107
- // src/args/index.ts
1102
+ // src/args/index.js
1108
1103
  import { readFileSync, existsSync } from "fs";
1109
1104
  import { resolve, dirname, basename, extname, join, isAbsolute } from "path";
1110
1105
  import { config } from "dotenv";
@@ -1587,10 +1582,10 @@ var Args = class _Args {
1587
1582
  }
1588
1583
  };
1589
1584
 
1590
- // src/params/index.ts
1585
+ // src/params/index.js
1591
1586
  import Joi from "joi";
1592
1587
 
1593
- // src/errors.ts
1588
+ // src/errors.js
1594
1589
  var FrameworkError = class extends Error {
1595
1590
  constructor(message) {
1596
1591
  super(message);
@@ -1616,7 +1611,7 @@ var FileDatabaseError = class extends FrameworkError {
1616
1611
  }
1617
1612
  };
1618
1613
 
1619
- // src/params/custom-types.ts
1614
+ // src/params/custom-types.js
1620
1615
  var joiEdateType = (value, helpers) => {
1621
1616
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(value)) {
1622
1617
  const testDate = new Date(value);
@@ -1709,7 +1704,7 @@ function calculateTimeOffset(amount, unit, sign) {
1709
1704
  }
1710
1705
  return sign === "+" ? amount * multiplier : -amount * multiplier;
1711
1706
  }
1712
- var joiStringArrayType = (type) => (value, helpers) => {
1707
+ var joiStringArrayType = (type) => (value, _helpers) => {
1713
1708
  if (value === void 0 || typeof value === "function") {
1714
1709
  return [];
1715
1710
  }
@@ -1735,7 +1730,7 @@ var joiStringArrayType = (type) => (value, helpers) => {
1735
1730
  return arr;
1736
1731
  };
1737
1732
 
1738
- // src/params/index.ts
1733
+ // src/params/index.js
1739
1734
  var Params = class _Params {
1740
1735
  context;
1741
1736
  // Partial context during initialization
@@ -1995,7 +1990,7 @@ var Params = class _Params {
1995
1990
  definition = val;
1996
1991
  val = val.value;
1997
1992
  }
1998
- const def = this.assignDefinition(key, definition);
1993
+ this.assignDefinition(key, definition);
1999
1994
  if (!this.runAllRegisteredSetters(key, val)) {
2000
1995
  this.params[key] = val;
2001
1996
  }
@@ -2054,6 +2049,18 @@ var Params = class _Params {
2054
2049
  this._currentModule = prev;
2055
2050
  }
2056
2051
  }
2052
+ /**
2053
+ * Async variant of {@link runWithModule} for modules that await params.get().
2054
+ */
2055
+ async runWithModuleAsync(moduleName, fn) {
2056
+ const prev = this._currentModule;
2057
+ this._currentModule = moduleName;
2058
+ try {
2059
+ return await fn();
2060
+ } finally {
2061
+ this._currentModule = prev;
2062
+ }
2063
+ }
2057
2064
  /**
2058
2065
  * Infer module name from call stack: first caller outside params/index gives path like .../src/<moduleName>/...
2059
2066
  */
@@ -2114,11 +2121,11 @@ var Params = class _Params {
2114
2121
  }
2115
2122
  };
2116
2123
 
2117
- // src/logger/index.ts
2124
+ // src/logger/index.js
2118
2125
  import chalk from "chalk";
2119
2126
  import util from "util";
2120
2127
 
2121
- // src/logger/transports.ts
2128
+ // src/logger/transports.js
2122
2129
  var ConsoleTransport = class {
2123
2130
  write(payload) {
2124
2131
  console.info(payload);
@@ -2138,7 +2145,7 @@ var ParentProcessTransport = class {
2138
2145
  }
2139
2146
  };
2140
2147
 
2141
- // src/logger/index.ts
2148
+ // src/logger/index.js
2142
2149
  var ALL_LEVELS = [
2143
2150
  "silly",
2144
2151
  "debug",
@@ -2211,6 +2218,7 @@ var Logger = class _Logger {
2211
2218
  }
2212
2219
  /**
2213
2220
  * Initialize logger from context and CLI parameters. Whatever is in options goes (after discovered params).
2221
+ * Params are tracked under the `logger` module for --showUsedParams.
2214
2222
  */
2215
2223
  static init(context, options) {
2216
2224
  const paramDefs = {
@@ -2224,7 +2232,7 @@ var Logger = class _Logger {
2224
2232
  progressWithTimes: "boolean default false",
2225
2233
  progressThrottleMs: "number"
2226
2234
  };
2227
- const discovered = context.params.getAllForModule(paramDefs);
2235
+ const discovered = context.params.getAllForModule("logger", paramDefs);
2228
2236
  const config2 = { ...discovered, ...options };
2229
2237
  const logger = new _Logger(context, config2);
2230
2238
  context.logger = logger;
@@ -2420,9 +2428,9 @@ var Logger = class _Logger {
2420
2428
  }
2421
2429
  };
2422
2430
 
2423
- // src/init/index.ts
2431
+ // src/init/index.js
2424
2432
  import { EventEmitter } from "events";
2425
- function extractComponentOptions(opts, componentName) {
2433
+ function extractComponentOptions(opts, _componentName) {
2426
2434
  const reservedKeys = ["overrides", "defaults", "modules"];
2427
2435
  const componentOptions = {};
2428
2436
  for (const [key, value] of Object.entries(opts)) {
@@ -2489,6 +2497,19 @@ function printAllParameters(context) {
2489
2497
  async function init(flow2, opts = {}) {
2490
2498
  let stop = false;
2491
2499
  let context = null;
2500
+ let cleanupRan = false;
2501
+ const runRegisteredCleanups = async (ctx) => {
2502
+ if (cleanupRan) return;
2503
+ cleanupRan = true;
2504
+ const fns = [...ctx.cleanupFunctions].reverse();
2505
+ for (const fn of fns) {
2506
+ try {
2507
+ await fn(ctx);
2508
+ } catch (error) {
2509
+ ctx.logger.warn("[cleanup] error in cleanup function:", error);
2510
+ }
2511
+ }
2512
+ };
2492
2513
  try {
2493
2514
  try {
2494
2515
  const screenModule = await Promise.resolve().then(() => (init_screen(), screen_exports));
@@ -2511,13 +2532,24 @@ async function init(flow2, opts = {}) {
2511
2532
  printAllParameters(context);
2512
2533
  process.exit(0);
2513
2534
  }
2535
+ let sigintCount = 0;
2514
2536
  process.on("SIGINT", async () => {
2515
- if (stop) {
2516
- context.logger.warn("[process] killed");
2517
- process.exit(2);
2537
+ if (!context) return;
2538
+ sigintCount += 1;
2539
+ if (sigintCount === 1) {
2540
+ stop = true;
2541
+ context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2542
+ context.emitter.emit("stop", stopAllowance);
2543
+ return;
2518
2544
  }
2545
+ context.logger.warn("[process] second SIGINT: running cleanup then exit");
2546
+ await runRegisteredCleanups(context);
2547
+ process.exit(2);
2548
+ });
2549
+ process.on("SIGTERM", () => {
2550
+ if (!context || stop) return;
2519
2551
  stop = true;
2520
- context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2552
+ context.logger.info(`>> SIGTERM: emitting stop with allowance ${stopAllowance}`);
2521
2553
  context.emitter.emit("stop", stopAllowance);
2522
2554
  });
2523
2555
  await flow2(context);
@@ -2542,33 +2574,95 @@ async function init(flow2, opts = {}) {
2542
2574
  }
2543
2575
  } finally {
2544
2576
  if (context) {
2545
- for (const fn of context.cleanupFunctions.reverse()) {
2546
- try {
2547
- await fn(context);
2548
- } catch (error) {
2549
- context.logger.warn("[cleanup] error in cleanup function:", error);
2550
- }
2551
- }
2577
+ await runRegisteredCleanups(context);
2552
2578
  }
2553
2579
  }
2554
2580
  }
2555
2581
 
2556
- // src/db/index.ts
2582
+ // src/db/index.js
2557
2583
  import knex from "knex";
2584
+ var KNEX_DEFAULTS = {
2585
+ testConnection: true,
2586
+ pool: { min: 2, max: 10 },
2587
+ acquireConnectionTimeout: 1e4,
2588
+ ssl: { rejectUnauthorized: false }
2589
+ };
2558
2590
  var Db = class {
2559
- knexInstance = null;
2560
- config;
2561
- logger;
2562
- queriesLog = [];
2563
- isConnected = false;
2564
- /**
2565
- * Constructor - accepts config object
2566
- * Use dbInit() function to initialize with Context
2567
- */
2591
+ static async init(context, options = {}) {
2592
+ const buildConfig = async () => {
2593
+ const defs2 = {
2594
+ dbName: "string",
2595
+ dbProfile: "boolean default false"
2596
+ };
2597
+ const discovered = context?.params?.getAllForModule?.("db", defs2) ?? {};
2598
+ const merged = { ...discovered, ...options };
2599
+ let { dbName, dbProfile } = merged;
2600
+ let dbConnectionString = options.dbConnectionString ?? options.connectionString;
2601
+ let connectionParam = dbConnectionString ? "options" : null;
2602
+ if (!dbConnectionString) {
2603
+ const src = context?.args?.getSource?.("dbConnectionString");
2604
+ if (src === "cli" || src === "overrides" || src === "config") {
2605
+ dbConnectionString = await context.params.get("dbConnectionString", "string");
2606
+ connectionParam = "dbConnectionString";
2607
+ }
2608
+ }
2609
+ if (!dbConnectionString && dbName && /^(postgresql|mysql):\/\//.test(dbName)) {
2610
+ dbConnectionString = dbName;
2611
+ dbName = void 0;
2612
+ }
2613
+ if (!dbConnectionString && dbName) {
2614
+ const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2615
+ dbConnectionString = await context.params.get(paramName, "string");
2616
+ connectionParam = paramName;
2617
+ if (!dbConnectionString) {
2618
+ throw new ParamError(
2619
+ `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2620
+ );
2621
+ }
2622
+ }
2623
+ if (!dbConnectionString) {
2624
+ dbConnectionString = await context.params.get("dbConnectionString", "string");
2625
+ if (dbConnectionString) {
2626
+ connectionParam = "dbConnectionString";
2627
+ }
2628
+ }
2629
+ if (!dbConnectionString) {
2630
+ if (!dbName) {
2631
+ dbName = "local";
2632
+ }
2633
+ const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2634
+ dbConnectionString = await context.params.get(paramName, "string");
2635
+ connectionParam = paramName;
2636
+ if (!dbConnectionString) {
2637
+ throw new ParamError(
2638
+ `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2639
+ );
2640
+ }
2641
+ }
2642
+ const displayName = resolveDbDisplayName(
2643
+ dbName,
2644
+ connectionParam,
2645
+ context?.args?.env,
2646
+ merged.name
2647
+ );
2648
+ return {
2649
+ ...KNEX_DEFAULTS,
2650
+ connectionString: dbConnectionString,
2651
+ name: displayName,
2652
+ profile: !!dbProfile,
2653
+ logger: context.logger
2654
+ };
2655
+ };
2656
+ const config2 = context?.params?.runWithModuleAsync ? await context.params.runWithModuleAsync("db", buildConfig) : await buildConfig();
2657
+ return dbConnect(context, config2);
2658
+ }
2568
2659
  constructor(config2) {
2569
- if (!config2.connectionString) {
2660
+ if (!config2 || !config2.connectionString) {
2570
2661
  throw new ParamError("Db: connectionString is required");
2571
2662
  }
2663
+ this.knexInstance = null;
2664
+ this.isConnected = false;
2665
+ this.queriesLog = [];
2572
2666
  this.config = {
2573
2667
  testConnection: true,
2574
2668
  profile: false,
@@ -2576,30 +2670,27 @@ var Db = class {
2576
2670
  acquireConnectionTimeout: 1e4,
2577
2671
  ssl: { rejectUnauthorized: false },
2578
2672
  logger: console,
2579
- name: "default",
2580
2673
  ...config2
2581
2674
  };
2582
2675
  this.logger = this.config.logger;
2583
2676
  const instance = this;
2584
- const callableWrapper = function(...args) {
2677
+ const callableWrapper = function() {
2585
2678
  throw new Error("This should never be called directly");
2586
2679
  };
2587
2680
  callableWrapper._instance = instance;
2588
2681
  return new Proxy(callableWrapper, {
2589
- // Intercept function calls: db('table')
2590
- apply: (target, thisArg, argumentsList) => {
2682
+ apply: (target, _thisArg, argumentsList) => {
2591
2683
  const inst = target._instance;
2592
2684
  if (!inst.knexInstance) {
2593
2685
  throw new Error("Db: Not connected. Call connect() first.");
2594
2686
  }
2595
2687
  return inst.knexInstance(...argumentsList);
2596
2688
  },
2597
- // Intercept property access: db.schema, db.raw, etc.
2598
2689
  get: (target, prop) => {
2599
2690
  if (prop === "_instance") {
2600
2691
  return target._instance;
2601
2692
  }
2602
- const instance2 = target._instance;
2693
+ const inst = target._instance;
2603
2694
  const ownMethods = [
2604
2695
  "connect",
2605
2696
  "disconnect",
@@ -2612,26 +2703,26 @@ var Db = class {
2612
2703
  "detectClient",
2613
2704
  "attachProfiler"
2614
2705
  ];
2615
- if (prop in instance2) {
2616
- const value = instance2[prop];
2706
+ if (prop in inst) {
2707
+ const value = inst[prop];
2617
2708
  if (typeof value === "function" && ownMethods.includes(prop)) {
2618
- return value.bind(instance2);
2709
+ return value.bind(inst);
2619
2710
  }
2620
2711
  if (typeof value !== "function") {
2621
2712
  return value;
2622
2713
  }
2623
2714
  }
2624
- if (instance2.knexInstance) {
2625
- const knexProp = instance2.knexInstance[prop];
2715
+ if (inst.knexInstance) {
2716
+ const knexProp = inst.knexInstance[prop];
2626
2717
  if (typeof knexProp === "function") {
2627
- return knexProp.bind(instance2.knexInstance);
2718
+ return knexProp.bind(inst.knexInstance);
2628
2719
  }
2629
2720
  return knexProp;
2630
2721
  }
2631
- if (prop in instance2) {
2632
- const method = instance2[prop];
2722
+ if (prop in inst) {
2723
+ const method = inst[prop];
2633
2724
  if (typeof method === "function") {
2634
- return method.bind(instance2);
2725
+ return method.bind(inst);
2635
2726
  }
2636
2727
  return method;
2637
2728
  }
@@ -2639,9 +2730,6 @@ var Db = class {
2639
2730
  }
2640
2731
  });
2641
2732
  }
2642
- /**
2643
- * Detect database client type from connection string
2644
- */
2645
2733
  detectClient(connectionString) {
2646
2734
  if (connectionString.match(/^postgresql/)) {
2647
2735
  return "pg";
@@ -2651,9 +2739,6 @@ var Db = class {
2651
2739
  }
2652
2740
  return null;
2653
2741
  }
2654
- /**
2655
- * Connect to the database
2656
- */
2657
2742
  async connect() {
2658
2743
  if (this.isConnected && this.knexInstance) {
2659
2744
  this.logger.warn?.("[Db] Already connected");
@@ -2662,14 +2747,13 @@ var Db = class {
2662
2747
  const client = this.detectClient(this.config.connectionString);
2663
2748
  if (!client) {
2664
2749
  throw new ParamError(
2665
- `Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://`
2750
+ "Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://"
2666
2751
  );
2667
2752
  }
2668
2753
  try {
2669
2754
  const connectionConfig = {
2670
2755
  connectionString: this.config.connectionString,
2671
2756
  family: 4
2672
- // Force IPv4 only (disable IPv6)
2673
2757
  };
2674
2758
  this.knexInstance = knex({
2675
2759
  client,
@@ -2685,7 +2769,7 @@ var Db = class {
2685
2769
  await this.testConnection();
2686
2770
  }
2687
2771
  this.isConnected = true;
2688
- this.logger.debug?.(`[Db] Connected to database "${this.config.name || this.config.connectionString}"`);
2772
+ this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
2689
2773
  } catch (error) {
2690
2774
  if (error instanceof ParamError) {
2691
2775
  throw error;
@@ -2694,9 +2778,6 @@ var Db = class {
2694
2778
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
2695
2779
  }
2696
2780
  }
2697
- /**
2698
- * Disconnect from the database
2699
- */
2700
2781
  async disconnect() {
2701
2782
  if (!this.knexInstance) {
2702
2783
  return;
@@ -2706,16 +2787,13 @@ var Db = class {
2706
2787
  this.knexInstance = null;
2707
2788
  this.isConnected = false;
2708
2789
  this.queriesLog = [];
2709
- this.logger.debug?.(`[Db] Disconnected from database "${this.config.name || this.config.connectionString}"`);
2790
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
2710
2791
  } catch (error) {
2711
2792
  const errorMsg = this.getErrorMessage(error);
2712
2793
  this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
2713
2794
  throw error;
2714
2795
  }
2715
2796
  }
2716
- /**
2717
- * Extract error message from various error types
2718
- */
2719
2797
  getErrorMessage(error) {
2720
2798
  if (error instanceof AggregateError) {
2721
2799
  const errors = error.errors || [];
@@ -2740,9 +2818,11 @@ var Db = class {
2740
2818
  return `${code} (tried: ${addresses.join(", ")})`;
2741
2819
  }
2742
2820
  }
2743
- const uniqueMessages = [...new Set(errors.map((e) => {
2744
- return e instanceof Error ? e.message : String(e);
2745
- }))];
2821
+ const uniqueMessages = [
2822
+ ...new Set(
2823
+ errors.map((e) => e instanceof Error ? e.message : String(e))
2824
+ )
2825
+ ];
2746
2826
  if (uniqueMessages.length === 1) {
2747
2827
  return uniqueMessages[0];
2748
2828
  }
@@ -2751,28 +2831,25 @@ var Db = class {
2751
2831
  return error.message || "Multiple errors occurred";
2752
2832
  }
2753
2833
  if (error instanceof Error) {
2754
- const errorWithCode = error;
2755
- if (errorWithCode.code) {
2756
- return `${errorWithCode.code}: ${error.message || String(error)}`;
2834
+ const code = error.code;
2835
+ if (code) {
2836
+ return `${code}: ${error.message || String(error)}`;
2757
2837
  }
2758
2838
  return error.message || String(error);
2759
2839
  }
2760
2840
  if (typeof error === "string") {
2761
2841
  return error;
2762
2842
  }
2763
- if (error?.message) {
2843
+ if (error && typeof error === "object" && "message" in error) {
2764
2844
  const msg = String(error.message);
2765
- const errorWithCode = error;
2766
- if (errorWithCode.code) {
2767
- return `${errorWithCode.code}: ${msg}`;
2845
+ const code = error.code;
2846
+ if (code) {
2847
+ return `${code}: ${msg}`;
2768
2848
  }
2769
2849
  return msg;
2770
2850
  }
2771
2851
  return String(error) || "Unknown error";
2772
2852
  }
2773
- /**
2774
- * Test database connection
2775
- */
2776
2853
  async testConnection() {
2777
2854
  if (!this.knexInstance) {
2778
2855
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2788,9 +2865,6 @@ var Db = class {
2788
2865
  throw new ParamError(`Db: Connection test failed - ${errorMsg}`);
2789
2866
  }
2790
2867
  }
2791
- /**
2792
- * Attach query profiler to log all queries
2793
- */
2794
2868
  attachProfiler() {
2795
2869
  if (!this.knexInstance) {
2796
2870
  return;
@@ -2800,7 +2874,7 @@ var Db = class {
2800
2874
  this.knexInstance.on("query", (query) => {
2801
2875
  query.__startTime = process.hrtime();
2802
2876
  });
2803
- this.knexInstance.on("query-response", (response, query) => {
2877
+ this.knexInstance.on("query-response", (_response, query) => {
2804
2878
  const [seconds, nanoseconds] = process.hrtime(query.__startTime);
2805
2879
  const executionTimeMs = (seconds * 1e3 + nanoseconds / 1e6).toFixed(2);
2806
2880
  const logEntry = {
@@ -2815,15 +2889,9 @@ var Db = class {
2815
2889
  this.logger.error?.(`[Db] Query failed: ${query.sql}`, error);
2816
2890
  });
2817
2891
  }
2818
- /**
2819
- * Get query log (only available if profiling is enabled)
2820
- */
2821
2892
  getQueryLog() {
2822
2893
  return [...this.queriesLog];
2823
2894
  }
2824
- /**
2825
- * Check if a table exists
2826
- */
2827
2895
  async tableExists(tableName) {
2828
2896
  if (!this.knexInstance) {
2829
2897
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2835,65 +2903,87 @@ var Db = class {
2835
2903
  throw error;
2836
2904
  }
2837
2905
  }
2838
- /**
2839
- * Get the underlying Knex instance (for advanced usage)
2840
- */
2841
2906
  getKnex() {
2842
2907
  if (!this.knexInstance) {
2843
2908
  throw new Error("Db: Not connected. Call connect() first.");
2844
2909
  }
2845
2910
  return this.knexInstance;
2846
2911
  }
2847
- /**
2848
- * Get connection status
2849
- */
2850
2912
  isConnectedToDb() {
2851
2913
  return this.isConnected && this.knexInstance !== null;
2852
2914
  }
2853
- /**
2854
- * Initialize Db with context (connects and registers disconnect cleanup).
2855
- * Params are read via getAllForModule("db", defs). Same as dbInit(context, dbNameOrConnectionString).
2856
- */
2857
- static async init(context, dbNameOrConnectionString) {
2858
- return dbFindAndConnect(context, dbNameOrConnectionString);
2859
- }
2860
2915
  };
2861
2916
  function capitalizeFirstLetter(str) {
2862
2917
  return str.charAt(0).toUpperCase() + str.slice(1);
2863
2918
  }
2864
- async function dbConnect(context, connectionString, name, dbProfile) {
2865
- const defs2 = {
2866
- testDbConnection: "boolean default true",
2867
- name: "string",
2868
- poolMin: "number default 2",
2869
- poolMax: "number default 10",
2870
- acquireConnectionTimeout: "number default 10000",
2871
- sslRejectUnauthorized: "boolean default false"
2872
- };
2873
- const paramsConfig = context.params.getAllForModule(defs2);
2874
- const config2 = {
2875
- connectionString,
2876
- name: paramsConfig.name || name || "default",
2877
- testConnection: paramsConfig.testDbConnection,
2878
- profile: dbProfile ?? false,
2879
- pool: {
2880
- min: paramsConfig.poolMin,
2881
- max: paramsConfig.poolMax
2882
- },
2883
- acquireConnectionTimeout: paramsConfig.acquireConnectionTimeout,
2884
- ssl: {
2885
- rejectUnauthorized: paramsConfig.sslRejectUnauthorized
2886
- },
2887
- logger: context.logger
2888
- };
2919
+ function resolveDbDisplayName(dbName, connectionParam, argsEnv, mergedName) {
2920
+ if (dbName) {
2921
+ return dbName;
2922
+ }
2923
+ if (mergedName) {
2924
+ return mergedName;
2925
+ }
2926
+ if (connectionParam?.startsWith("dbConnectionString") && connectionParam.length > "dbConnectionString".length) {
2927
+ return connectionParam.slice("dbConnectionString".length).toLowerCase();
2928
+ }
2929
+ if (connectionParam === "dbConnectionString" && argsEnv) {
2930
+ return argsEnv;
2931
+ }
2932
+ return void 0;
2933
+ }
2934
+ function formatDbConnectMessage(name, connectionString) {
2935
+ const endpointSuffix = formatConnectionEndpointSuffix(connectionString);
2936
+ if (name) {
2937
+ return `[Db] Connected to database "${name}"${endpointSuffix}`;
2938
+ }
2939
+ return `[Db] Connected${endpointSuffix}`;
2940
+ }
2941
+ function formatDbDisconnectMessage(name, connectionString) {
2942
+ const endpointSuffix = formatConnectionEndpointSuffix(connectionString);
2943
+ if (name) {
2944
+ return `[Db] Disconnected from database "${name}"${endpointSuffix}`;
2945
+ }
2946
+ return `[Db] Disconnected${endpointSuffix}`;
2947
+ }
2948
+ function formatDbInstanceMessage(action, name) {
2949
+ if (name) {
2950
+ return `[Db] instance "${name}" ${action}`;
2951
+ }
2952
+ return `[Db] instance ${action}`;
2953
+ }
2954
+ function formatConnectionEndpointSuffix(connectionString) {
2955
+ const endpoint = formatConnectionEndpoint(connectionString);
2956
+ return endpoint ? ` (${endpoint})` : "";
2957
+ }
2958
+ function formatConnectionEndpoint(connectionString) {
2959
+ try {
2960
+ const url = new URL(connectionString);
2961
+ const host = url.hostname;
2962
+ if (!host) {
2963
+ return null;
2964
+ }
2965
+ let port = url.port;
2966
+ if (!port) {
2967
+ if (url.protocol === "postgresql:") {
2968
+ port = "5432";
2969
+ } else if (url.protocol === "mysql:") {
2970
+ port = "3306";
2971
+ }
2972
+ }
2973
+ return port ? `${host}:${port}` : host;
2974
+ } catch {
2975
+ return null;
2976
+ }
2977
+ }
2978
+ async function dbConnect(context, config2) {
2889
2979
  try {
2890
2980
  const db = new Db(config2);
2891
2981
  context.registerCleanup(async () => {
2892
2982
  await db.disconnect();
2893
- context.logger.debug(`[Db] instance "${name || connectionString}" destroyed`);
2983
+ context.logger.debug?.(formatDbInstanceMessage("disconnected", config2.name));
2894
2984
  });
2895
2985
  await db.connect();
2896
- context.logger.debug(`[Db] instance "${name || connectionString}" initialized`);
2986
+ context.logger.debug?.(formatDbInstanceMessage("initialized", config2.name));
2897
2987
  return db;
2898
2988
  } catch (error) {
2899
2989
  if (error instanceof ParamError) {
@@ -2903,48 +2993,11 @@ async function dbConnect(context, connectionString, name, dbProfile) {
2903
2993
  throw new ParamError(`[Db] connect error: ${errorMsg}`);
2904
2994
  }
2905
2995
  }
2906
- async function dbFindAndConnect(context, dbNameOrConnectionString) {
2907
- let dbName;
2908
- let dbConnectionString;
2909
- let dbProfile;
2910
- if (dbNameOrConnectionString) {
2911
- if (dbNameOrConnectionString.match(/^(postgresql|mysql):\/\/[^\s]+:[^\s]+@[^\s]+:\d+\/[^\s]+$/)) {
2912
- dbName = void 0;
2913
- dbConnectionString = dbNameOrConnectionString;
2914
- } else {
2915
- dbName = dbNameOrConnectionString;
2916
- }
2917
- } else {
2918
- const defs2 = {
2919
- dbName: "string",
2920
- dbConnectionString: "string",
2921
- dbProfile: "boolean default false"
2922
- };
2923
- const paramsConfig = context.params.getAll(defs2);
2924
- dbName = paramsConfig.dbName;
2925
- dbConnectionString = paramsConfig.dbConnectionString;
2926
- dbProfile = paramsConfig.dbProfile;
2927
- }
2928
- if (!dbName && !dbConnectionString) {
2929
- throw new ParamError("Db: either dbName or dbConnectionString must be specified");
2930
- }
2931
- if (dbName) {
2932
- const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2933
- dbConnectionString = await context.params.get(paramName, "string");
2934
- if (!dbConnectionString) {
2935
- throw new ParamError(
2936
- `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2937
- );
2938
- }
2939
- }
2940
- const db = await dbConnect(context, dbConnectionString, dbName, dbProfile);
2941
- return db;
2942
- }
2943
- async function dbInit(context, dbNameOrConnectionString) {
2944
- return await dbFindAndConnect(context, dbNameOrConnectionString);
2945
- }
2946
2996
 
2947
- // src/utils/date-utils.ts
2997
+ // src/tasks/index.js
2998
+ import os3 from "os";
2999
+
3000
+ // src/utils/date-utils.js
2948
3001
  function isTimestampFolder(folderName) {
2949
3002
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|\.\d{3}Z)$/;
2950
3003
  if (!isoRegex.test(folderName)) {
@@ -2954,7 +3007,7 @@ function isTimestampFolder(folderName) {
2954
3007
  return !isNaN(date.getTime()) && date.getTime() > 0;
2955
3008
  }
2956
3009
 
2957
- // src/utils/fs-utils.ts
3010
+ // src/utils/fs-utils.js
2958
3011
  import fs from "fs";
2959
3012
  import path from "path";
2960
3013
  async function ensurePath(...pathParts) {
@@ -2978,7 +3031,7 @@ function getFileExtension(dataType) {
2978
3031
  }
2979
3032
  }
2980
3033
 
2981
- // src/utils/os-utils.ts
3034
+ // src/utils/os-utils.js
2982
3035
  import fs2 from "fs";
2983
3036
  import path2 from "path";
2984
3037
  import { execSync } from "child_process";
@@ -3007,7 +3060,7 @@ function getFreeDiskSpace(targetPath) {
3007
3060
  }
3008
3061
  }
3009
3062
 
3010
- // src/utils/format-utils.ts
3063
+ // src/utils/format-utils.js
3011
3064
  function bytesToHumanReadable(bytes) {
3012
3065
  if (bytes === 0) return "0 B";
3013
3066
  const k = 1024;
@@ -3016,7 +3069,7 @@ function bytesToHumanReadable(bytes) {
3016
3069
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
3017
3070
  }
3018
3071
 
3019
- // src/utils/core-utils.ts
3072
+ // src/utils/core-utils.js
3020
3073
  function sleepMs(ms) {
3021
3074
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3022
3075
  }
@@ -3025,14 +3078,82 @@ function toJsonColumn(value) {
3025
3078
  return JSON.stringify(value);
3026
3079
  }
3027
3080
 
3028
- // src/tasks/servicesRegistry.ts
3029
- import { randomUUID as randomUUID2 } from "crypto";
3030
- import { mkdir, readFile, writeFile } from "fs/promises";
3081
+ // src/tasks/servicesRegistry.js
3031
3082
  import os from "os";
3032
- import path3 from "path";
3033
3083
 
3034
- // src/tasks/taskUtils.ts
3084
+ // src/tasks/taskUtils.js
3035
3085
  import { randomUUID } from "crypto";
3086
+
3087
+ // src/tasks/time-matcher.js
3088
+ var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
3089
+ function resolveAsterisks(field, range) {
3090
+ return field.includes("*") ? field.replace("*", range) : field;
3091
+ }
3092
+ function resolveRanges(field) {
3093
+ const regex = /(\d+)-(\d+)/;
3094
+ let current = field;
3095
+ while (true) {
3096
+ const match = regex.exec(current);
3097
+ if (!match) break;
3098
+ const raw = match[0];
3099
+ let first = Number(match[1]);
3100
+ let last = Number(match[2]);
3101
+ if (last < first) {
3102
+ [first, last] = [last, first];
3103
+ }
3104
+ const values = [];
3105
+ for (let i = first; i <= last; i += 1) {
3106
+ values.push(i);
3107
+ }
3108
+ current = current.replace(raw, values.join(","));
3109
+ }
3110
+ return current;
3111
+ }
3112
+ function resolveSteps(field) {
3113
+ const match = /^(.+)\/(\d+)$/.exec(field);
3114
+ if (!match) return field;
3115
+ const base = match[1];
3116
+ const step = Number(match[2]);
3117
+ if (!Number.isFinite(step) || step <= 0) return field;
3118
+ return base.split(",").map((v) => Number(v)).filter((v) => Number.isFinite(v) && v % step === 0).join(",");
3119
+ }
3120
+ function convertPattern(pattern) {
3121
+ const parts = pattern.trim().split(/\s+/);
3122
+ if (parts.length !== 6) {
3123
+ throw new Error(`Invalid schedule "${pattern}". Expected 6 fields: sec min hour day month weekday`);
3124
+ }
3125
+ return parts.map((field, idx) => resolveAsterisks(field, RANGES[idx])).map((field) => resolveRanges(field)).map((field) => resolveSteps(field));
3126
+ }
3127
+ function fieldMatches(field, value) {
3128
+ const allowed = field.split(",").map((v) => Number(v));
3129
+ return allowed.includes(value);
3130
+ }
3131
+ function matchesParsedPattern(parsed, date) {
3132
+ 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());
3133
+ }
3134
+ function timeMatcher(pattern, date = /* @__PURE__ */ new Date()) {
3135
+ const parsed = convertPattern(pattern);
3136
+ return matchesParsedPattern(parsed, date);
3137
+ }
3138
+ var MS_PER_SECOND = 1e3;
3139
+ var DEFAULT_SEARCH_HORIZON_MS = 10 * 365 * 24 * 60 * 60 * MS_PER_SECOND;
3140
+ function nextTimeMatch(pattern, from = /* @__PURE__ */ new Date(), maxSearchMs = DEFAULT_SEARCH_HORIZON_MS) {
3141
+ const parsed = convertPattern(pattern);
3142
+ let t = Math.ceil((from.getTime() + 1) / MS_PER_SECOND) * MS_PER_SECOND;
3143
+ const end = t + maxSearchMs;
3144
+ while (t <= end) {
3145
+ const date = new Date(t);
3146
+ if (matchesParsedPattern(parsed, date)) {
3147
+ return date;
3148
+ }
3149
+ t += MS_PER_SECOND;
3150
+ }
3151
+ throw new Error(
3152
+ `nextTimeMatch: no match for "${pattern}" within ${maxSearchMs}ms after ${from.toISOString()}`
3153
+ );
3154
+ }
3155
+
3156
+ // src/tasks/taskUtils.js
3036
3157
  function getDb(context) {
3037
3158
  const db = context.db;
3038
3159
  if (!db) {
@@ -3040,97 +3161,84 @@ function getDb(context) {
3040
3161
  }
3041
3162
  return db;
3042
3163
  }
3043
- function queueToTableNames(queue) {
3044
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
3045
- throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
3046
- }
3164
+ function queueToTableNames(queueName) {
3047
3165
  return {
3048
- tasksTable: queue,
3049
- historyTable: `${queue}_history`
3166
+ tasksTable: queueName,
3167
+ historyTable: `${queueName}_history`,
3168
+ registryTable: `${queueName}_services_registry`
3050
3169
  };
3051
3170
  }
3052
- function servicesRegistryTable(queue) {
3053
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
3054
- throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
3055
- }
3056
- return `${queue}_services_registry`;
3171
+ function defineTasksTable(t, db, tableNameForIndex) {
3172
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3173
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3174
+ t.timestamp("started_at");
3175
+ t.timestamp("completed_at");
3176
+ t.integer("priority").notNullable().defaultTo(50);
3177
+ t.text("schedule");
3178
+ t.timestamp("next_run_at").defaultTo(null);
3179
+ t.timestamp("past_due").defaultTo(null);
3180
+ t.text("name").notNullable();
3181
+ t.text("opid");
3182
+ t.json("params");
3183
+ t.text("service_group");
3184
+ t.integer("instance_number");
3185
+ t.text("service_name");
3186
+ t.text("server_name");
3187
+ t.text("status").notNullable().defaultTo("idle");
3188
+ t.timestamp("status_changed_at").defaultTo(null);
3189
+ t.text("progress");
3190
+ t.boolean("success");
3191
+ t.json("results");
3192
+ t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
3193
+ t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
3194
+ }
3195
+ function taskHistoryInsertFromQueueRow(row, overrides) {
3196
+ const { id, ...snapshot } = row;
3197
+ void id;
3198
+ return {
3199
+ ...snapshot,
3200
+ ...overrides
3201
+ };
3057
3202
  }
3058
3203
  async function ensureTaskTables(context, options = {}) {
3059
- const queue = options.queue ?? "tasks";
3204
+ const queueName = options.queueName ?? "tasks";
3060
3205
  const recreate = options.recreate ?? false;
3061
3206
  const db = getDb(context);
3062
- const { tasksTable, historyTable } = queueToTableNames(queue);
3207
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
3063
3208
  const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
3064
3209
  const needsHistory = recreate ? true : !await db.tableExists(historyTable);
3210
+ const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
3065
3211
  if (recreate) {
3066
3212
  await db.schema.dropTableIfExists(historyTable);
3067
3213
  await db.schema.dropTableIfExists(tasksTable);
3214
+ await db.schema.dropTableIfExists(registryTable);
3068
3215
  }
3069
3216
  if (needsTasks) {
3070
3217
  await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
3071
3218
  await db.schema.createTable(tasksTable, (t) => {
3072
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3073
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3074
- t.timestamp("started_at");
3075
- t.timestamp("completed_at");
3076
- t.integer("priority").notNullable().defaultTo(0);
3077
- t.text("schedule");
3078
- t.timestamp("past_due").defaultTo(null);
3079
- t.text("target").notNullable();
3080
- t.text("task").notNullable();
3081
- t.json("params");
3082
- t.text("opid");
3083
- t.timestamp("paused_at").defaultTo(null);
3084
- t.text("progress");
3085
- t.boolean("success");
3086
- t.json("results");
3087
- });
3088
- await db.schema.alterTable(tasksTable, (t) => {
3089
- t.index(["target", "started_at", "created_at"], `${tasksTable}_target_started_created_idx`);
3090
- t.index(["target", "past_due", "priority", "created_at"], `${tasksTable}_target_past_due_priority_created_idx`);
3091
- t.index(["target", "task"], `${tasksTable}_target_task_idx`);
3219
+ defineTasksTable(t, db, tasksTable);
3092
3220
  });
3093
3221
  }
3094
3222
  if (needsHistory) {
3095
3223
  await db.schema.createTable(historyTable, (t) => {
3096
- t.uuid("id").notNullable();
3097
- t.timestamp("created_at").notNullable();
3098
- t.timestamp("started_at");
3099
- t.timestamp("completed_at");
3100
- t.integer("priority").notNullable().defaultTo(0);
3101
- t.text("schedule");
3102
- t.timestamp("past_due").defaultTo(null);
3103
- t.text("target").notNullable();
3104
- t.text("task").notNullable();
3105
- t.json("params");
3106
- t.text("opid");
3107
- t.text("progress");
3108
- t.boolean("success");
3109
- t.json("results");
3110
- });
3111
- await db.schema.alterTable(historyTable, (t) => {
3112
- t.index(["target", "created_at"], `${historyTable}_target_created_idx`);
3113
- t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
3224
+ defineTasksTable(t, db, historyTable);
3114
3225
  });
3115
3226
  }
3116
- const registryTable = servicesRegistryTable(queue);
3117
- const needsRegistry = !await db.tableExists(registryTable);
3118
3227
  if (needsRegistry) {
3119
3228
  await db.schema.createTable(registryTable, (t) => {
3120
3229
  t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3121
- t.uuid("instance_id").notNullable().unique();
3122
- t.text("queue").notNullable();
3230
+ t.text("queue_name").notNullable();
3123
3231
  t.text("service_group").notNullable();
3232
+ t.integer("instance_number").notNullable().defaultTo(1);
3124
3233
  t.text("service_name").notNullable();
3125
- t.text("target").notNullable();
3126
- t.text("hostname");
3234
+ t.text("server_name").notNullable();
3127
3235
  t.integer("pid");
3128
3236
  t.json("metadata");
3129
3237
  t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3130
3238
  t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
3131
- t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
3132
- t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
3133
- t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
3239
+ t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
3240
+ t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
3241
+ t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
3134
3242
  });
3135
3243
  }
3136
3244
  }
@@ -3141,7 +3249,7 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
3141
3249
  });
3142
3250
  }
3143
3251
 
3144
- // src/tasks/servicesRegistry.ts
3252
+ // src/tasks/servicesRegistry.js
3145
3253
  function getDb2(context) {
3146
3254
  const db = context.db;
3147
3255
  if (!db) {
@@ -3152,6 +3260,7 @@ function getDb2(context) {
3152
3260
  var DEFAULT_GROUP_MAX_INSTANCES = {
3153
3261
  intake: 1,
3154
3262
  harvest: 1,
3263
+ harvester: 0,
3155
3264
  loader: 0,
3156
3265
  photos: 0,
3157
3266
  photosprocessor: 0,
@@ -3161,25 +3270,6 @@ function sanitizeNamePart(raw) {
3161
3270
  const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
3162
3271
  return s.slice(0, 80) || "runner";
3163
3272
  }
3164
- function identityFilePath(identityDir, queue, serviceGroup) {
3165
- const safeQ = sanitizeNamePart(queue);
3166
- const safeG = sanitizeNamePart(serviceGroup);
3167
- return path3.join(identityDir, `${safeQ}_${safeG}.json`);
3168
- }
3169
- async function readIdentityFile(filePath) {
3170
- try {
3171
- const text = await readFile(filePath, "utf8");
3172
- const parsed = JSON.parse(text);
3173
- return parsed && typeof parsed === "object" ? parsed : {};
3174
- } catch {
3175
- return {};
3176
- }
3177
- }
3178
- async function writeIdentityFile(filePath, data) {
3179
- await mkdir(path3.dirname(filePath), { recursive: true });
3180
- await writeFile(filePath, `${JSON.stringify(data, null, 2)}
3181
- `, "utf8");
3182
- }
3183
3273
  function resolveMaxInstances(serviceGroup, override) {
3184
3274
  if (override !== void 0 && Number.isFinite(override)) {
3185
3275
  return Math.max(0, Math.floor(Number(override)));
@@ -3187,184 +3277,198 @@ function resolveMaxInstances(serviceGroup, override) {
3187
3277
  const g = serviceGroup.trim().toLowerCase();
3188
3278
  return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
3189
3279
  }
3190
- async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
3280
+ async function countAliveInGroup(db, registryTable, queueName, serviceGroup, staleMs, excludeRowId) {
3191
3281
  const cutoff = new Date(Date.now() - staleMs);
3192
- let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
3193
- if (excludeInstanceId) {
3194
- q = q.whereNot("instance_id", excludeInstanceId);
3282
+ let q = db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
3283
+ if (excludeRowId) {
3284
+ q = q.whereNot("id", excludeRowId);
3195
3285
  }
3196
3286
  const row = await q.count("id as count").first();
3197
3287
  return Number(row?.count ?? 0);
3198
3288
  }
3289
+ async function getOccupiedInstanceSlots(db, registryTable, queueName, serviceGroup, staleMs) {
3290
+ const cutoff = new Date(Date.now() - staleMs);
3291
+ const rows = await db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff).select("instance_number");
3292
+ const set = /* @__PURE__ */ new Set();
3293
+ for (const r of rows) {
3294
+ const n = Number(r.instance_number);
3295
+ if (Number.isFinite(n) && n >= 1) set.add(Math.floor(n));
3296
+ }
3297
+ return set;
3298
+ }
3199
3299
  function isUniqueViolation(error) {
3200
3300
  const code = error?.code ?? error?.errno;
3201
3301
  return code === "23505" || String(error?.message || "").includes("duplicate key");
3202
3302
  }
3303
+ function buildMetadata(options) {
3304
+ const base = options.metadata && typeof options.metadata === "object" ? { ...options.metadata } : {};
3305
+ if (options.target) {
3306
+ base.runnerTarget = options.target;
3307
+ }
3308
+ return toJsonColumn(Object.keys(base).length ? base : null);
3309
+ }
3310
+ function allocateInstanceNumber(occupied, explicit, maxSlots) {
3311
+ if (explicit !== void 0 && explicit !== null && Number.isFinite(Number(explicit))) {
3312
+ const e = Math.max(1, Math.floor(Number(explicit)));
3313
+ if (occupied.has(e)) {
3314
+ throw new Error(`[services-registry] instance slot ${e} is already occupied by an alive peer`);
3315
+ }
3316
+ if (maxSlots !== void 0 && maxSlots > 0 && e > maxSlots) {
3317
+ throw new Error(`[services-registry] instance ${e} exceeds configured max slots ${maxSlots} for this group`);
3318
+ }
3319
+ return e;
3320
+ }
3321
+ const cap = maxSlots !== void 0 && maxSlots > 0 ? maxSlots : 1e4;
3322
+ for (let n = 1; n <= cap; n++) {
3323
+ if (!occupied.has(n)) return n;
3324
+ }
3325
+ throw new Error(`[services-registry] no free instance slot (searched 1..${cap})`);
3326
+ }
3327
+ function defaultServiceName(groupBase, hostBase, instanceNumber) {
3328
+ return `${groupBase}-${hostBase}-${instanceNumber}`;
3329
+ }
3203
3330
  async function registerInServicesRegistry(context, options) {
3204
3331
  const db = getDb2(context);
3205
- const registryTable = servicesRegistryTable(options.queue);
3332
+ const registryTable = queueToTableNames(options.queueName).registryTable;
3206
3333
  const serviceGroup = options.serviceGroup.trim();
3207
3334
  if (!serviceGroup) {
3208
3335
  throw new Error("registerInServicesRegistry: serviceGroup is required");
3209
3336
  }
3210
- const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
3211
- let identity = await readIdentityFile(identityPath);
3212
- let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : randomUUID2();
3213
- identity.instanceId = instanceId;
3214
- await writeIdentityFile(identityPath, identity);
3215
- const hostname = os.hostname();
3337
+ const serverName = os.hostname();
3216
3338
  const pid = typeof process.pid === "number" ? process.pid : null;
3217
- const meta = toJsonColumn(options.metadata ?? null);
3218
- const existing = await db(registryTable).where({ instance_id: instanceId }).first();
3219
- if (existing) {
3220
- await db(registryTable).where({ instance_id: instanceId }).update({
3221
- target: options.target,
3222
- hostname,
3223
- pid,
3224
- metadata: meta,
3225
- last_seen_at: db.fn.now()
3226
- });
3227
- const serviceName = String(existing.service_name);
3228
- identity.serviceName = serviceName;
3229
- await writeIdentityFile(identityPath, identity);
3230
- const reg = {
3231
- instanceId,
3232
- serviceName,
3233
- serviceGroup,
3234
- queue: options.queue,
3235
- target: options.target,
3236
- rowId: String(existing.id)
3237
- };
3238
- context.servicesRegistry = reg;
3239
- context.runnerHeartbeat = reg;
3240
- context.logger.info?.(
3241
- `[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
3242
- );
3243
- return {
3244
- instanceId,
3245
- serviceName,
3246
- serviceGroup,
3247
- queue: options.queue,
3248
- target: options.target,
3249
- rowId: String(existing.id),
3250
- registryTable
3251
- };
3252
- }
3339
+ const meta = buildMetadata(options);
3340
+ const groupBase = sanitizeNamePart(serviceGroup);
3341
+ const hostBase = sanitizeNamePart(serverName);
3253
3342
  const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
3254
- const aliveOthers = await countAliveInGroup(
3255
- db,
3256
- registryTable,
3257
- options.queue,
3258
- serviceGroup,
3259
- options.staleMs,
3260
- instanceId
3261
- );
3262
- if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
3263
- const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
3343
+ const aliveCount = await countAliveInGroup(db, registryTable, options.queueName, serviceGroup, options.staleMs, void 0);
3344
+ if (maxAllowed > 0 && aliveCount >= maxAllowed) {
3345
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveCount} alive (max ${maxAllowed}, queue=${options.queueName}).`;
3264
3346
  if (options.enforceMaxInstances) {
3265
3347
  throw new Error(msg);
3266
3348
  }
3267
3349
  context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
3268
3350
  }
3269
- const explicitName = options.serviceName?.trim();
3270
- const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
3271
- const hostBase = sanitizeNamePart(hostname);
3272
- const groupBase = sanitizeNamePart(serviceGroup);
3273
- const baseCandidates = [];
3274
- if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
3275
- if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
3276
- baseCandidates.push(`${groupBase}-${hostBase}`);
3277
- baseCandidates.push(groupBase);
3278
- function* eachServiceNameCandidate(bases) {
3279
- const seen = /* @__PURE__ */ new Set();
3280
- for (const rawBase of bases) {
3281
- const base = sanitizeNamePart(rawBase);
3282
- if (!base) continue;
3283
- const seq = [base];
3284
- for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
3285
- for (const c of seq) {
3286
- if (seen.has(c)) continue;
3287
- seen.add(c);
3288
- yield c;
3351
+ const maxSlots = maxAllowed > 0 ? maxAllowed : void 0;
3352
+ const cutoff = new Date(Date.now() - options.staleMs);
3353
+ const MAX_ATTEMPTS = 8;
3354
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
3355
+ const occupied = await getOccupiedInstanceSlots(db, registryTable, options.queueName, serviceGroup, options.staleMs);
3356
+ const instanceNumber = allocateInstanceNumber(occupied, options.instanceNumber, maxSlots);
3357
+ const serviceNameRaw = options.serviceName?.trim() ? sanitizeNamePart(options.serviceName.trim()) : defaultServiceName(groupBase, hostBase, instanceNumber);
3358
+ const existing = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3359
+ if (existing) {
3360
+ const lastSeen = new Date(existing.last_seen_at);
3361
+ const isAlive = !Number.isNaN(lastSeen.getTime()) && lastSeen > cutoff;
3362
+ if (isAlive) {
3363
+ if (options.serviceName?.trim()) {
3364
+ throw new Error(
3365
+ `[services-registry] service_name "${serviceNameRaw}" is already registered by an alive peer`
3366
+ );
3367
+ }
3368
+ context.logger.warn?.(
3369
+ `[services-registry] service_name "${serviceNameRaw}" already alive; retrying allocation (attempt ${attempt + 1})`
3370
+ );
3371
+ if (options.instanceNumber !== void 0 && options.instanceNumber !== null) {
3372
+ throw new Error(
3373
+ `[services-registry] instance slot ${instanceNumber} / name "${serviceNameRaw}" is already held by an alive peer`
3374
+ );
3375
+ }
3376
+ await new Promise((r) => setTimeout(r, 50 + attempt * 30));
3377
+ continue;
3289
3378
  }
3379
+ await db(registryTable).where({ id: existing.id }).update({
3380
+ server_name: serverName,
3381
+ pid,
3382
+ metadata: meta,
3383
+ service_group: serviceGroup,
3384
+ instance_number: instanceNumber,
3385
+ last_seen_at: db.fn.now()
3386
+ });
3387
+ const reg = {
3388
+ serviceName: serviceNameRaw,
3389
+ serviceGroup,
3390
+ queueName: options.queueName,
3391
+ target: options.target,
3392
+ rowId: String(existing.id),
3393
+ registryTable,
3394
+ instanceNumber
3395
+ };
3396
+ context.servicesRegistry = reg;
3397
+ context.runnerHeartbeat = reg;
3398
+ context.logger.info?.(
3399
+ `[services-registry] took over stale row name=${serviceNameRaw} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3400
+ );
3401
+ return reg;
3290
3402
  }
3291
- }
3292
- let inserted;
3293
- for (const candidate of eachServiceNameCandidate(baseCandidates)) {
3294
3403
  try {
3295
3404
  const rows = await db(registryTable).insert({
3296
- instance_id: instanceId,
3297
- queue: options.queue,
3405
+ queue_name: options.queueName,
3298
3406
  service_group: serviceGroup,
3299
- service_name: candidate,
3300
- target: options.target,
3301
- hostname,
3407
+ instance_number: instanceNumber,
3408
+ service_name: serviceNameRaw,
3409
+ server_name: serverName,
3302
3410
  pid,
3303
3411
  metadata: meta,
3304
- last_seen_at: db.fn.now()
3412
+ last_seen_at: db.fn.now(),
3413
+ created_at: db.fn.now()
3305
3414
  }).returning(["id", "service_name"]);
3306
3415
  const row = Array.isArray(rows) ? rows[0] : rows;
3307
- if (row) {
3308
- inserted = { id: String(row.id), service_name: String(row.service_name) };
3309
- break;
3416
+ let rowId = row && typeof row === "object" ? String(row.id ?? "") : "";
3417
+ if (!rowId) {
3418
+ const again = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3419
+ rowId = again?.id != null ? String(again.id) : "";
3310
3420
  }
3421
+ if (!rowId) continue;
3422
+ const regNew = {
3423
+ serviceName: String(row?.service_name ?? serviceNameRaw),
3424
+ serviceGroup,
3425
+ queueName: options.queueName,
3426
+ target: options.target,
3427
+ rowId,
3428
+ registryTable,
3429
+ instanceNumber
3430
+ };
3431
+ context.servicesRegistry = regNew;
3432
+ context.runnerHeartbeat = regNew;
3433
+ context.logger.info?.(
3434
+ `[services-registry] registered name=${regNew.serviceName} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3435
+ );
3436
+ return regNew;
3311
3437
  } catch (error) {
3312
3438
  if (!isUniqueViolation(error)) {
3313
3439
  throw error;
3314
3440
  }
3441
+ context.logger.warn?.(`[services-registry] insert race on "${serviceNameRaw}", retrying (attempt ${attempt + 1})`);
3315
3442
  }
3316
3443
  }
3317
- if (!inserted) {
3318
- throw new Error(
3319
- `[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
3320
- );
3321
- }
3322
- identity.serviceName = inserted.service_name;
3323
- await writeIdentityFile(identityPath, identity);
3324
- const regNew = {
3325
- instanceId,
3326
- serviceName: inserted.service_name,
3327
- serviceGroup,
3328
- queue: options.queue,
3329
- target: options.target,
3330
- rowId: inserted.id
3331
- };
3332
- context.servicesRegistry = regNew;
3333
- context.runnerHeartbeat = regNew;
3334
- context.logger.info?.(
3335
- `[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
3444
+ throw new Error(
3445
+ `[services-registry] could not allocate a registry row for group=${serviceGroup} queue=${options.queueName} after ${MAX_ATTEMPTS} attempts`
3336
3446
  );
3337
- return {
3338
- instanceId,
3339
- serviceName: inserted.service_name,
3340
- serviceGroup,
3341
- queue: options.queue,
3342
- target: options.target,
3343
- rowId: inserted.id,
3344
- registryTable
3345
- };
3346
3447
  }
3347
3448
  async function touchServicesRegistry(context, registration) {
3348
3449
  const db = getDb2(context);
3349
- const hostname = os.hostname();
3450
+ const serverName = os.hostname();
3350
3451
  const pid = typeof process.pid === "number" ? process.pid : null;
3351
- await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
3452
+ await db(registration.registryTable).where({ id: registration.rowId }).update({
3352
3453
  last_seen_at: db.fn.now(),
3353
- hostname,
3454
+ server_name: serverName,
3354
3455
  pid
3355
3456
  });
3356
3457
  }
3357
3458
  async function unregisterServicesRegistry(context, registration) {
3358
3459
  const db = getDb2(context);
3359
- await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
3360
- context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
3460
+ await db(registration.registryTable).where({ id: registration.rowId }).delete();
3461
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} id=${registration.rowId}`);
3361
3462
  }
3362
3463
 
3363
- // src/filedatabase/index.ts
3364
- import fs3 from "fs";
3464
+ // src/tasks/taskLogs.js
3365
3465
  import path4 from "path";
3366
3466
 
3367
- // src/filedatabase/serializers.ts
3467
+ // src/filedatabase/index.js
3468
+ import fs3 from "fs";
3469
+ import path3 from "path";
3470
+
3471
+ // src/filedatabase/serializers.js
3368
3472
  function detectDataType(data) {
3369
3473
  if (Array.isArray(data)) {
3370
3474
  return "json-array";
@@ -3396,7 +3500,7 @@ function deserializeData(rawData, dataType) {
3396
3500
  }
3397
3501
  }
3398
3502
 
3399
- // src/filedatabase/index.ts
3503
+ // src/filedatabase/index.js
3400
3504
  var FileDatabase = class _FileDatabase {
3401
3505
  basePath;
3402
3506
  namespace;
@@ -3482,14 +3586,14 @@ var FileDatabase = class _FileDatabase {
3482
3586
  if (errors.length) {
3483
3587
  throw new FileDatabaseError(`[FileDatabase] ${errors.join("; ")}`);
3484
3588
  }
3485
- let parts = [this.basePath, this.namespace];
3589
+ const parts = [this.basePath, this.namespace];
3486
3590
  if (this.tableName) {
3487
3591
  parts.push(...this.tableName.split("/"));
3488
3592
  }
3489
3593
  if (this.versioned && version) {
3490
3594
  parts.push(version);
3491
3595
  }
3492
- return path4.resolve(...parts);
3596
+ return path3.resolve(...parts);
3493
3597
  }
3494
3598
  /**
3495
3599
  * Set current version and version folder
@@ -3526,7 +3630,7 @@ var FileDatabase = class _FileDatabase {
3526
3630
  this.currentFileNumber = 0;
3527
3631
  const versions = await this.getVersions();
3528
3632
  while (versions.length > this.maxVersions) {
3529
- const versionToDelete = path4.resolve(this.getDestinationPath(), versions.shift());
3633
+ const versionToDelete = path3.resolve(this.getDestinationPath(), versions.shift());
3530
3634
  this.logger.silly?.(`[FileDatabase] Deleting old version: ${versionToDelete}`);
3531
3635
  await fs3.promises.rm(versionToDelete, { recursive: true, force: true });
3532
3636
  }
@@ -3545,7 +3649,7 @@ var FileDatabase = class _FileDatabase {
3545
3649
  await ensurePath(destPath);
3546
3650
  const items = await fs3.promises.readdir(destPath);
3547
3651
  const versions = items.filter((item) => {
3548
- const itemPath = path4.join(destPath, item);
3652
+ const itemPath = path3.join(destPath, item);
3549
3653
  const stat = fs3.statSync(itemPath);
3550
3654
  return stat.isDirectory() && isTimestampFolder(item);
3551
3655
  });
@@ -3602,7 +3706,7 @@ var FileDatabase = class _FileDatabase {
3602
3706
  const items = await fs3.promises.readdir(tablePath);
3603
3707
  if (items.includes("metadata.json")) {
3604
3708
  const metadata = JSON.parse(
3605
- await fs3.promises.readFile(path4.join(tablePath, "metadata.json"), "utf8")
3709
+ await fs3.promises.readFile(path3.join(tablePath, "metadata.json"), "utf8")
3606
3710
  );
3607
3711
  return {
3608
3712
  versioned: false,
@@ -3611,13 +3715,13 @@ var FileDatabase = class _FileDatabase {
3611
3715
  };
3612
3716
  }
3613
3717
  const versionFolders = items.filter((item) => {
3614
- const itemPath = path4.join(tablePath, item);
3718
+ const itemPath = path3.join(tablePath, item);
3615
3719
  const stat = fs3.statSync(itemPath);
3616
3720
  return stat.isDirectory() && isTimestampFolder(item);
3617
3721
  });
3618
3722
  if (versionFolders.length > 0) {
3619
3723
  const latestVersion = versionFolders.sort().pop();
3620
- const versionMetadataPath = path4.join(tablePath, latestVersion, "metadata.json");
3724
+ const versionMetadataPath = path3.join(tablePath, latestVersion, "metadata.json");
3621
3725
  return {
3622
3726
  versioned: true,
3623
3727
  hasMetadata: fs3.existsSync(versionMetadataPath),
@@ -3638,7 +3742,7 @@ var FileDatabase = class _FileDatabase {
3638
3742
  * Load metadata from JSON file
3639
3743
  */
3640
3744
  async loadMetadataJson(version) {
3641
- const metadataFile = path4.join(this.getDestinationPath(), version, "metadata.json");
3745
+ const metadataFile = path3.join(this.getDestinationPath(), version, "metadata.json");
3642
3746
  if (fs3.existsSync(metadataFile)) {
3643
3747
  try {
3644
3748
  const rawData = await fs3.promises.readFile(metadataFile, "utf8");
@@ -3654,7 +3758,7 @@ var FileDatabase = class _FileDatabase {
3654
3758
  * Reads all files to get accurate counts - used when synopsis calculation is needed
3655
3759
  */
3656
3760
  async figureMetadataFromVersionFiles(version) {
3657
- const versionPath = path4.join(this.getDestinationPath(), version);
3761
+ const versionPath = path3.join(this.getDestinationPath(), version);
3658
3762
  if (!fs3.existsSync(versionPath)) {
3659
3763
  return this.getDefaultMetadata();
3660
3764
  }
@@ -3666,10 +3770,10 @@ var FileDatabase = class _FileDatabase {
3666
3770
  let detectedDataType = null;
3667
3771
  for (let i = 0; i < files.length; i++) {
3668
3772
  const fileName = files[i];
3669
- const filePath = path4.join(versionPath, fileName);
3773
+ const filePath = path3.join(versionPath, fileName);
3670
3774
  try {
3671
3775
  const rawData = await fs3.promises.readFile(filePath, "utf8");
3672
- const extension = path4.extname(fileName).toLowerCase();
3776
+ const extension = path3.extname(fileName).toLowerCase();
3673
3777
  let dataType = "text";
3674
3778
  if (extension === ".json") {
3675
3779
  dataType = "json-array";
@@ -3702,7 +3806,7 @@ var FileDatabase = class _FileDatabase {
3702
3806
  * Much faster for large datasets with many files
3703
3807
  */
3704
3808
  async buildMetadataOptimized(version) {
3705
- const versionPath = path4.join(this.getDestinationPath(), version);
3809
+ const versionPath = path3.join(this.getDestinationPath(), version);
3706
3810
  if (!fs3.existsSync(versionPath)) {
3707
3811
  return this.getDefaultMetadata();
3708
3812
  }
@@ -3718,7 +3822,7 @@ var FileDatabase = class _FileDatabase {
3718
3822
  fileName
3719
3823
  }));
3720
3824
  const firstFile = metadata.files[0];
3721
- const firstFilePath = path4.join(versionPath, firstFile.fileName);
3825
+ const firstFilePath = path3.join(versionPath, firstFile.fileName);
3722
3826
  const firstFileRaw = await fs3.promises.readFile(firstFilePath, "utf8");
3723
3827
  let firstFileData;
3724
3828
  try {
@@ -3735,7 +3839,7 @@ var FileDatabase = class _FileDatabase {
3735
3839
  }
3736
3840
  if (files.length > 1) {
3737
3841
  const lastFile = metadata.files[metadata.files.length - 1];
3738
- const lastFilePath = path4.join(versionPath, lastFile.fileName);
3842
+ const lastFilePath = path3.join(versionPath, lastFile.fileName);
3739
3843
  const lastFileRaw = await fs3.promises.readFile(lastFilePath, "utf8");
3740
3844
  const lastFileData = deserializeData(lastFileRaw, metadata.dataType);
3741
3845
  lastFile.recordsCount = Array.isArray(lastFileData) ? lastFileData.length : 1;
@@ -3786,9 +3890,9 @@ var FileDatabase = class _FileDatabase {
3786
3890
  if (!this.currentVersion) {
3787
3891
  return;
3788
3892
  }
3789
- metadataFile = path4.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
3893
+ metadataFile = path3.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
3790
3894
  } else {
3791
- metadataFile = path4.join(this.getDestinationPath(), "metadata.json");
3895
+ metadataFile = path3.join(this.getDestinationPath(), "metadata.json");
3792
3896
  }
3793
3897
  await fs3.promises.writeFile(metadataFile, JSON.stringify(metadataToSave, null, 4), "utf8");
3794
3898
  }
@@ -3851,7 +3955,7 @@ var FileDatabase = class _FileDatabase {
3851
3955
  }
3852
3956
  }
3853
3957
  if (!Array.isArray(data) && !forceNewFile) {
3854
- const lastFileExtension = path4.extname(lastFile.fileName);
3958
+ const lastFileExtension = path3.extname(lastFile.fileName);
3855
3959
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
3856
3960
  if (lastFileExtension !== expectedExtension) {
3857
3961
  if (lastFileRecordsCount > 0) {
@@ -3861,7 +3965,7 @@ var FileDatabase = class _FileDatabase {
3861
3965
  }
3862
3966
  }
3863
3967
  } else if (!Array.isArray(data) && forceNewFile) {
3864
- const lastFileExtension = path4.extname(lastFile.fileName);
3968
+ const lastFileExtension = path3.extname(lastFile.fileName);
3865
3969
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
3866
3970
  if (lastFileExtension !== expectedExtension) {
3867
3971
  lastFile.fileName = `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(incomingDataType)}`;
@@ -3951,7 +4055,7 @@ var FileDatabase = class _FileDatabase {
3951
4055
  */
3952
4056
  async safeWrite(filePath, data) {
3953
4057
  const serializedData = serializeData(data);
3954
- const dir = path4.dirname(filePath);
4058
+ const dir = path3.dirname(filePath);
3955
4059
  const requiredBytes = Buffer.byteLength(serializedData, "utf8");
3956
4060
  const freeBytes = getFreeDiskSpace(dir);
3957
4061
  if (freeBytes !== null) {
@@ -3975,14 +4079,17 @@ var FileDatabase = class _FileDatabase {
3975
4079
  * Prepare the instance for read or write operations
3976
4080
  * This discovers state and sets up internal members based on mode and current data
3977
4081
  */
3978
- async prepare({ write, read, version }) {
4082
+ async prepare(options) {
4083
+ const { write, read, version, deferInitialVersion } = options;
3979
4084
  if (write) {
3980
4085
  if (this.versioned) {
3981
4086
  if (this.currentVersion === null) {
3982
- await this.makeNewVersion();
3983
- this.metadata = this.getDefaultMetadata();
3984
- this.metadata.version = this.currentVersion;
3985
- this.makeNewFile();
4087
+ if (!deferInitialVersion) {
4088
+ await this.makeNewVersion();
4089
+ this.metadata = this.getDefaultMetadata();
4090
+ this.metadata.version = this.currentVersion;
4091
+ this.makeNewFile();
4092
+ }
3986
4093
  } else {
3987
4094
  if (!this.metadata.files.length) {
3988
4095
  this.metadata = await this.figureMetadata(this.currentVersion);
@@ -3996,7 +4103,7 @@ var FileDatabase = class _FileDatabase {
3996
4103
  } else {
3997
4104
  await ensurePath(this.getDestinationPath());
3998
4105
  if (this.useMetadata === true) {
3999
- const metadataPath = path4.join(this.getDestinationPath(), "metadata.json");
4106
+ const metadataPath = path3.join(this.getDestinationPath(), "metadata.json");
4000
4107
  if (fs3.existsSync(metadataPath)) {
4001
4108
  try {
4002
4109
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -4049,7 +4156,7 @@ var FileDatabase = class _FileDatabase {
4049
4156
  }
4050
4157
  if (this.useMetadata) {
4051
4158
  const destPath = this.getDestinationPath();
4052
- const metadataPath = path4.join(destPath, "metadata.json");
4159
+ const metadataPath = path3.join(destPath, "metadata.json");
4053
4160
  if (fs3.existsSync(metadataPath)) {
4054
4161
  try {
4055
4162
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -4085,14 +4192,14 @@ var FileDatabase = class _FileDatabase {
4085
4192
  if (options.filename) {
4086
4193
  const destPath2 = this.getDestinationPath();
4087
4194
  await ensurePath(destPath2);
4088
- const filePath = path4.join(destPath2, options.filename);
4195
+ const filePath = path3.join(destPath2, options.filename);
4089
4196
  await this.safeWrite(filePath, data);
4090
4197
  return;
4091
4198
  }
4092
4199
  if (options.forceNewVersion && !this.versioned) {
4093
4200
  throw new FileDatabaseError("Cannot use forceNewVersion in non-versioned mode");
4094
4201
  }
4095
- await this.prepare({ write: true });
4202
+ await this.prepare({ write: true, deferInitialVersion: !!(options.forceNewVersion && this.versioned) });
4096
4203
  const incomingDataType = detectDataType(data);
4097
4204
  this.metadata.dataType = incomingDataType;
4098
4205
  if (options.forceNewVersion) {
@@ -4134,11 +4241,11 @@ var FileDatabase = class _FileDatabase {
4134
4241
  const forceNewFile = hasCustomMetadata && targetFileIndex === null;
4135
4242
  let { dataToWrite, dataLeftOver, fileName } = this.figureOutDataAndFileToWrite(data, targetFileIndex, forceNewFile);
4136
4243
  const destPath = this.getDestinationPath(this.currentVersion || void 0);
4137
- await this.safeWrite(path4.join(destPath, fileName), dataToWrite);
4244
+ await this.safeWrite(path3.join(destPath, fileName), dataToWrite);
4138
4245
  this.updateMetadata(dataToWrite, fileName, options.customMetadata);
4139
4246
  while (dataLeftOver && dataLeftOver.length > 0 && targetFileIndex === null) {
4140
4247
  const writeContext = this.figureOutDataAndFileToWrite(dataLeftOver);
4141
- await this.safeWrite(path4.join(destPath, writeContext.fileName), writeContext.dataToWrite);
4248
+ await this.safeWrite(path3.join(destPath, writeContext.fileName), writeContext.dataToWrite);
4142
4249
  this.updateMetadata(writeContext.dataToWrite, writeContext.fileName, options.customMetadata);
4143
4250
  dataLeftOver = writeContext.dataLeftOver;
4144
4251
  }
@@ -4154,7 +4261,7 @@ var FileDatabase = class _FileDatabase {
4154
4261
  const { version, nextPage = false, pageSize, filename } = options;
4155
4262
  if (filename) {
4156
4263
  const destPath = this.getDestinationPath(version);
4157
- const filePath = path4.join(destPath, filename);
4264
+ const filePath = path3.join(destPath, filename);
4158
4265
  try {
4159
4266
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4160
4267
  return JSON.parse(rawData);
@@ -4166,7 +4273,7 @@ var FileDatabase = class _FileDatabase {
4166
4273
  const isNonPaginatedData = this.metadata.dataType === "text" || this.metadata.dataType === "xml" || this.metadata.dataType === "json-object";
4167
4274
  if (isNonPaginatedData) {
4168
4275
  const file = this.metadata.files[0];
4169
- const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4276
+ const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4170
4277
  try {
4171
4278
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4172
4279
  return deserializeData(rawData, this.metadata.dataType);
@@ -4204,7 +4311,7 @@ var FileDatabase = class _FileDatabase {
4204
4311
  let cumulativeRecords = currentFileOffset;
4205
4312
  for (let i = currentFileIndex; i < this.metadata.files.length && recordsRead < effectivePageSize; i++) {
4206
4313
  const file = this.metadata.files[i];
4207
- const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4314
+ const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4208
4315
  try {
4209
4316
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4210
4317
  const fileData = deserializeData(rawData, this.metadata.dataType);
@@ -4248,7 +4355,7 @@ var FileDatabase = class _FileDatabase {
4248
4355
  * Returns data file names (.json, .txt, .xml) excluding metadata.json.
4249
4356
  */
4250
4357
  async listFilenames() {
4251
- const destPath = this.versioned && this.currentVersion ? path4.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4358
+ const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4252
4359
  try {
4253
4360
  const entries = await fs3.promises.readdir(destPath, { withFileTypes: true });
4254
4361
  return entries.filter((e) => e.isFile() && e.name !== "metadata.json" && /\.(json|txt|xml)$/i.test(e.name)).map((e) => e.name);
@@ -4262,8 +4369,8 @@ var FileDatabase = class _FileDatabase {
4262
4369
  * Use with listFilenames() to manage individual files.
4263
4370
  */
4264
4371
  async removeFile(filename) {
4265
- const destPath = this.versioned && this.currentVersion ? path4.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4266
- const filePath = path4.join(destPath, filename);
4372
+ const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4373
+ const filePath = path3.join(destPath, filename);
4267
4374
  try {
4268
4375
  await fs3.promises.unlink(filePath);
4269
4376
  } catch (err) {
@@ -4289,7 +4396,7 @@ var FileDatabase = class _FileDatabase {
4289
4396
  this.metadata.files.splice(idx, 1);
4290
4397
  this.metadata.totalRecords = Math.max(0, (this.metadata.totalRecords || 0) - recordsCount);
4291
4398
  const destPath = this.getDestinationPath();
4292
- const filePath = path4.join(destPath, filename);
4399
+ const filePath = path3.join(destPath, filename);
4293
4400
  try {
4294
4401
  await fs3.promises.unlink(filePath);
4295
4402
  } catch (err) {
@@ -4345,7 +4452,7 @@ var FileDatabase = class _FileDatabase {
4345
4452
  });
4346
4453
  if (matches) {
4347
4454
  const destPath = this.getDestinationPath();
4348
- const filePath = path4.join(destPath, fileEntry.fileName);
4455
+ const filePath = path3.join(destPath, fileEntry.fileName);
4349
4456
  const fileData = await fs3.promises.readFile(filePath, "utf8");
4350
4457
  const data = deserializeData(fileData, metadata.dataType || "json-object");
4351
4458
  results.push({
@@ -4368,7 +4475,7 @@ var FileDatabase = class _FileDatabase {
4368
4475
  });
4369
4476
  if (matches) {
4370
4477
  const destPath = this.getDestinationPath(version);
4371
- const filePath = path4.join(destPath, fileEntry.fileName);
4478
+ const filePath = path3.join(destPath, fileEntry.fileName);
4372
4479
  const fileData = await fs3.promises.readFile(filePath, "utf8");
4373
4480
  const data = deserializeData(fileData, metadata.dataType || "json-object");
4374
4481
  results.push({
@@ -4386,7 +4493,7 @@ var FileDatabase = class _FileDatabase {
4386
4493
  }
4387
4494
  };
4388
4495
 
4389
- // src/tasks/taskLogs.ts
4496
+ // src/tasks/taskLogs.js
4390
4497
  function getLogsState(context) {
4391
4498
  const holder = context;
4392
4499
  if (holder.__tasksLogsState) return holder.__tasksLogsState;
@@ -4439,33 +4546,128 @@ function getLogsState(context) {
4439
4546
  holder.__tasksLogsState = state;
4440
4547
  return state;
4441
4548
  }
4442
- function isErrorPayload(payload) {
4443
- if (!payload) return false;
4444
- if (typeof payload === "object") {
4445
- const level = typeof payload.level === "string" ? payload.level.toLowerCase() : "";
4446
- if (level === "error" || level === "fatal") return true;
4447
- if (typeof payload.message === "string" && /\berror\b/i.test(payload.message)) return true;
4448
- return false;
4449
- }
4450
- if (typeof payload === "string") {
4451
- return /\berror\b/i.test(payload);
4452
- }
4453
- return false;
4549
+ function ipcLogTargetKey(target) {
4550
+ const bp = target.basePath ?? "";
4551
+ const ns = target.namespace ?? "";
4552
+ return `${bp}::${ns}::${target.tableName}`;
4454
4553
  }
4455
- function buildLogRecord(task, payload) {
4456
- const params = task.params && typeof task.params === "object" ? task.params : {};
4457
- return {
4458
- ts: (/* @__PURE__ */ new Date()).toISOString(),
4554
+ function ipcFileLogsTableNameForSourceResource(source, resource) {
4555
+ const seg = (s) => {
4556
+ const t = String(s).trim().replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
4557
+ return t.length ? t : "x";
4558
+ };
4559
+ return `${seg(source)}/${seg(resource)}`;
4560
+ }
4561
+ async function readTaskIpcLogsSnapshot(context, options) {
4562
+ const holder = context;
4563
+ const basePath = holder.params?.get?.("tasksLogsBasePath") ?? "./data";
4564
+ const namespace = holder.params?.get?.("tasksLogsNamespace") ?? "tasks-logs";
4565
+ const tableName = ipcFileLogsTableNameForSourceResource(options.source, options.resource);
4566
+ const tail = Math.max(1, Math.min(1e4, Number(options.tail) > 0 ? Number(options.tail) : 100));
4567
+ const fd = new FileDatabase({
4568
+ basePath,
4569
+ namespace,
4570
+ tableName,
4571
+ versioned: true,
4572
+ useMetadata: true,
4573
+ maxVersions: 30,
4574
+ pageSize: 2e3,
4575
+ logger: holder.logger
4576
+ });
4577
+ const versions = await fd.getVersions();
4578
+ if (versions.length === 0) {
4579
+ return { records: [], latestTs: null };
4580
+ }
4581
+ const latest = versions[versions.length - 1];
4582
+ const raw = await fd.read({ version: latest });
4583
+ const arr = Array.isArray(raw) ? raw : [];
4584
+ let filtered = arr;
4585
+ if (options.afterTs && String(options.afterTs).trim()) {
4586
+ const cut = String(options.afterTs).trim();
4587
+ filtered = arr.filter((r) => r && typeof r.ts === "string" && String(r.ts) > cut);
4588
+ }
4589
+ let latestTs = null;
4590
+ for (const r of filtered) {
4591
+ const ts = typeof r?.ts === "string" ? String(r.ts) : null;
4592
+ if (ts && (!latestTs || ts > latestTs)) latestTs = ts;
4593
+ }
4594
+ const incremental = !!(options.afterTs && String(options.afterTs).trim());
4595
+ const maxReturn = incremental ? 1e4 : tail;
4596
+ const sliced = filtered.length > maxReturn ? filtered.slice(-maxReturn) : filtered;
4597
+ return { records: sliced, latestTs };
4598
+ }
4599
+ function getLogsStateForTarget(context, target) {
4600
+ const holder = context;
4601
+ const enabledRaw = holder.params?.get?.("tasksLogsEnabled");
4602
+ const enabled = enabledRaw === void 0 ? true : !!enabledRaw;
4603
+ if (!enabled) return null;
4604
+ if (!holder.__tasksLogsTargetStates) holder.__tasksLogsTargetStates = /* @__PURE__ */ new Map();
4605
+ const map = holder.__tasksLogsTargetStates;
4606
+ const key = ipcLogTargetKey(target);
4607
+ if (map.has(key)) return map.get(key);
4608
+ const basePath = target.basePath ?? (holder.params?.get?.("tasksLogsBasePath") || "./data");
4609
+ const namespace = target.namespace ?? (holder.params?.get?.("tasksLogsNamespace") || "tasks-logs");
4610
+ const maxVersionsRaw = Number(holder.params?.get?.("tasksLogsMaxVersions"));
4611
+ const pageSizeRaw = Number(holder.params?.get?.("tasksLogsPageSize"));
4612
+ const db = new FileDatabase({
4613
+ basePath,
4614
+ namespace,
4615
+ tableName: target.tableName,
4616
+ versioned: true,
4617
+ useMetadata: true,
4618
+ maxVersions: Number.isFinite(maxVersionsRaw) && maxVersionsRaw > 0 ? maxVersionsRaw : 20,
4619
+ pageSize: Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? pageSizeRaw : 2e3,
4620
+ logger: holder.logger
4621
+ });
4622
+ const state = {
4623
+ db,
4624
+ errorDb: null,
4625
+ queue: Promise.resolve(),
4626
+ initialized: false,
4627
+ errorInitialized: false
4628
+ };
4629
+ map.set(key, state);
4630
+ return state;
4631
+ }
4632
+ function isErrorPayload(payload) {
4633
+ if (!payload) return false;
4634
+ if (typeof payload === "object") {
4635
+ const level = typeof payload.level === "string" ? payload.level.toLowerCase() : "";
4636
+ if (level === "error" || level === "fatal") return true;
4637
+ if (typeof payload.message === "string" && /\berror\b/i.test(payload.message)) return true;
4638
+ return false;
4639
+ }
4640
+ if (typeof payload === "string") {
4641
+ return /\berror\b/i.test(payload);
4642
+ }
4643
+ return false;
4644
+ }
4645
+ function buildLogRecord(task, payload) {
4646
+ const params = task.params && typeof task.params === "object" ? task.params : {};
4647
+ return {
4648
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4459
4649
  opid: task.opid ?? null,
4460
4650
  taskId: task.id,
4461
- taskName: task.task,
4462
- target: task.target,
4651
+ taskName: task.name,
4652
+ target: task.service_group,
4463
4653
  source: typeof params.source === "string" ? params.source : null,
4464
4654
  resource: typeof params.resource === "string" ? params.resource : null,
4465
4655
  payload
4466
4656
  };
4467
4657
  }
4468
- function appendTaskIpcLog(context, task, payload) {
4658
+ function appendTaskIpcLog(context, task, payload, target) {
4659
+ if (target) {
4660
+ const state2 = getLogsStateForTarget(context, target);
4661
+ if (!state2?.db) return;
4662
+ const record2 = buildLogRecord(task, payload);
4663
+ state2.queue = state2.queue.then(async () => {
4664
+ await state2.db.write([record2], { forceNewVersion: !state2.initialized });
4665
+ state2.initialized = true;
4666
+ }).catch((error) => {
4667
+ context.logger.warn?.("[tasks] failed to persist IPC log entry (targeted):", error);
4668
+ });
4669
+ return;
4670
+ }
4469
4671
  const state = getLogsState(context);
4470
4672
  if (!state.db && !state.errorDb) return;
4471
4673
  const record = buildLogRecord(task, payload);
@@ -4483,83 +4685,280 @@ function appendTaskIpcLog(context, task, payload) {
4483
4685
  });
4484
4686
  }
4485
4687
 
4486
- // src/tasks/time-matcher.ts
4487
- var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
4488
- function resolveAsterisks(field, range) {
4489
- return field.includes("*") ? field.replace("*", range) : field;
4490
- }
4491
- function resolveRanges(field) {
4492
- const regex = /(\d+)-(\d+)/;
4493
- let current = field;
4494
- while (true) {
4495
- const match = regex.exec(current);
4496
- if (!match) break;
4497
- const raw = match[0];
4498
- let first = Number(match[1]);
4499
- let last = Number(match[2]);
4500
- if (last < first) {
4501
- [first, last] = [last, first];
4502
- }
4503
- const values = [];
4504
- for (let i = first; i <= last; i += 1) {
4505
- values.push(i);
4506
- }
4507
- current = current.replace(raw, values.join(","));
4508
- }
4509
- return current;
4510
- }
4511
- function resolveSteps(field) {
4512
- const match = /^(.+)\/(\d+)$/.exec(field);
4513
- if (!match) return field;
4514
- const base = match[1];
4515
- const step = Number(match[2]);
4516
- if (!Number.isFinite(step) || step <= 0) return field;
4517
- return base.split(",").map((v) => Number(v)).filter((v) => Number.isFinite(v) && v % step === 0).join(",");
4518
- }
4519
- function convertPattern(pattern) {
4520
- const parts = pattern.trim().split(/\s+/);
4521
- if (parts.length !== 6) {
4522
- throw new Error(`Invalid schedule "${pattern}". Expected 6 fields: sec min hour day month weekday`);
4523
- }
4524
- return parts.map((field, idx) => resolveAsterisks(field, RANGES[idx])).map((field) => resolveRanges(field)).map((field) => resolveSteps(field));
4525
- }
4526
- function fieldMatches(field, value) {
4527
- const allowed = field.split(",").map((v) => Number(v));
4528
- return allowed.includes(value);
4529
- }
4530
- function timeMatcher(pattern, date = /* @__PURE__ */ new Date()) {
4531
- const parsed = convertPattern(pattern);
4532
- 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());
4533
- }
4534
-
4535
- // src/tasks/TaskMaster.ts
4536
- var TaskMaster = class {
4537
- context;
4538
- task;
4688
+ // src/tasks/AbstractTask.js
4689
+ var AbstractTask = class _AbstractTask {
4690
+ /**
4691
+ * Whether `send-task` should wait for completion (and print a result
4692
+ * report) when no explicit `--wait` / `--noWait` flag is given. Defaults
4693
+ * to false; short-lived probe tasks (e.g. `ping`) override to true.
4694
+ *
4695
+ * @type {boolean}
4696
+ */
4697
+ static defaultWaitForResult = false;
4698
+ /**
4699
+ * @param {object} context Runner context (db, logger, params, emitter...).
4700
+ * @param {object} task Task row as claimed from the queue.
4701
+ */
4539
4702
  constructor(context, task) {
4540
4703
  this.context = context;
4541
4704
  this.task = task;
4542
4705
  }
4706
+ /**
4707
+ * Return a short reason string when the task should be deferred (e.g. "locked
4708
+ * by source"), or `false`/falsy when it is free to run. Default: always `false`.
4709
+ *
4710
+ * @returns {string | false | Promise<string | false>}
4711
+ */
4543
4712
  cantRunReason() {
4544
4713
  return false;
4545
4714
  }
4715
+ /**
4716
+ * Called by the runner when a stop has been requested. Subclasses running
4717
+ * long loops should flip a flag here and check it between iterations.
4718
+ *
4719
+ * @param {number} [_allowanceMs] Grace period the runner promises before hard exit.
4720
+ */
4546
4721
  requestStop(_allowanceMs) {
4547
4722
  }
4723
+ /**
4724
+ * Perform the task. Must be implemented by subclasses.
4725
+ *
4726
+ * @param {(progress: unknown) => Promise<void>} _reportProgress
4727
+ * Updates the DB `progress` column. Accepts any serializable value;
4728
+ * strings are stored verbatim, objects are JSON-stringified.
4729
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4730
+ */
4731
+ async run(_reportProgress) {
4732
+ throw new Error("AbstractTask.run must be implemented by subclass");
4733
+ }
4734
+ /**
4735
+ * Resolve a complete row payload for this task — envelope fields (queue,
4736
+ * priority, targeting, schedule…) plus the inner `params` blob produced by
4737
+ * {@link AbstractTask.resolveCustomParams}. Output shape matches
4738
+ * {@link enqueueTask}'s `options` argument, so the typical call is:
4739
+ *
4740
+ * const payload = await TaskClass.resolveParams(context, { name });
4741
+ * await enqueueTask(context, payload);
4742
+ *
4743
+ * Validation failures throw {@link ParamError} so the script aborts before
4744
+ * a malformed row hits the DB.
4745
+ *
4746
+ * @param {object} context
4747
+ * @param {Record<string, unknown>} [overrides] Partial overrides; takes precedence over CLI/env.
4748
+ * Recognised keys: `name`, `queueName`, `priority`, `serviceGroup`,
4749
+ * `serviceName`, `instanceNumber`, `serverName`, `opid`, `schedule`,
4750
+ * `nextRunAt`, plus `params` (object — overlay onto inner blob).
4751
+ * @returns {Promise<object>}
4752
+ */
4753
+ static async resolveParams(context, overrides = {}) {
4754
+ const main = _AbstractTask._resolveMainFields(context, overrides);
4755
+ const params = await this.resolveCustomParams(context, overrides);
4756
+ return { ...main, params };
4757
+ }
4758
+ /**
4759
+ * Resolve the inner JSON blob stored in the `params` column. Default
4760
+ * implementation passes through `--paramsJson` (parsed as a JSON object)
4761
+ * overlaid with `overrides.params` when supplied; returns `null` when
4762
+ * neither is provided.
4763
+ *
4764
+ * Subclasses with typed fields should override and call
4765
+ * {@link AbstractTask._mergeTypedParams} for layered CLI/env/JSON/override
4766
+ * resolution, then validate and throw {@link ParamError} on bad input.
4767
+ *
4768
+ * @param {object} context
4769
+ * @param {Record<string, unknown>} [overrides]
4770
+ * @returns {Promise<object|null>}
4771
+ */
4772
+ static async resolveCustomParams(context, overrides = {}) {
4773
+ return _AbstractTask._defaultParamsBlob(context, overrides);
4774
+ }
4775
+ /**
4776
+ * Read main task envelope fields from `context.params` (CLI/env), with
4777
+ * any matching key on `overrides` taking precedence. Internal; called by
4778
+ * {@link AbstractTask.resolveParams}.
4779
+ *
4780
+ * @param {object} context
4781
+ * @param {Record<string, unknown>} [overrides]
4782
+ * @returns {object}
4783
+ */
4784
+ static _resolveMainFields(context, overrides = {}) {
4785
+ const defs2 = {
4786
+ queueName: "string default tasks",
4787
+ priority: "number default 50",
4788
+ serviceGroup: "string",
4789
+ serviceName: "string",
4790
+ instanceNumber: "number",
4791
+ serverName: "string",
4792
+ opid: "string",
4793
+ schedule: "string"
4794
+ };
4795
+ const cli = context.params.getAllForModule("task-envelope", defs2);
4796
+ const name = emptyToUndef(overrides.name) ?? emptyToUndef(context.params.get("name", "string"));
4797
+ if (!name) {
4798
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
4799
+ }
4800
+ let instanceNumber;
4801
+ const rawInstance = overrides.instanceNumber ?? cli.instanceNumber;
4802
+ if (rawInstance !== void 0 && rawInstance !== null && String(rawInstance).trim() !== "") {
4803
+ const n = Number(rawInstance);
4804
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
4805
+ throw new ParamError("--instanceNumber must be a positive integer when set");
4806
+ }
4807
+ instanceNumber = n;
4808
+ } else {
4809
+ instanceNumber = null;
4810
+ }
4811
+ const priorityRaw = overrides.priority ?? cli.priority ?? 50;
4812
+ const priority = Number(priorityRaw);
4813
+ if (!Number.isFinite(priority)) {
4814
+ throw new ParamError(`--priority must be a number (got ${JSON.stringify(priorityRaw)})`);
4815
+ }
4816
+ return {
4817
+ name,
4818
+ queueName: overrides.queueName ?? emptyToUndef(cli.queueName) ?? "tasks",
4819
+ priority,
4820
+ serviceGroup: emptyToUndef(overrides.serviceGroup) ?? emptyToUndef(cli.serviceGroup) ?? null,
4821
+ serviceName: emptyToUndef(overrides.serviceName) ?? emptyToUndef(cli.serviceName) ?? null,
4822
+ instanceNumber,
4823
+ serverName: emptyToUndef(overrides.serverName) ?? emptyToUndef(cli.serverName) ?? null,
4824
+ opid: emptyToUndef(overrides.opid) ?? emptyToUndef(cli.opid) ?? null,
4825
+ schedule: emptyToUndef(overrides.schedule) ?? emptyToUndef(cli.schedule) ?? null,
4826
+ nextRunAt: overrides.nextRunAt ?? null
4827
+ };
4828
+ }
4829
+ /**
4830
+ * Default inner-params resolver: parses `--paramsJson` (must be a JSON
4831
+ * object), then overlays `overrides.params` on top. Returns `null` when
4832
+ * neither is provided.
4833
+ *
4834
+ * @param {object} context
4835
+ * @param {Record<string, unknown>} [overrides]
4836
+ * @returns {object|null}
4837
+ */
4838
+ static _defaultParamsBlob(context, overrides = {}) {
4839
+ const cli = context.params.getAllForModule("task-params", { paramsJson: "string" });
4840
+ const fromJson = parseParamsJson(cli.paramsJson);
4841
+ const fromOverride = pickParamsObject(overrides);
4842
+ if (!fromJson && !fromOverride) return null;
4843
+ return { ...fromJson ?? {}, ...fromOverride ?? {} };
4844
+ }
4845
+ /**
4846
+ * Helper for subclass `resolveCustomParams` overrides. Reads typed CLI
4847
+ * params (per `defs`) plus `--paramsJson` under a module namespace, then
4848
+ * merges them with explicit `overrides.params` in increasing priority:
4849
+ *
4850
+ * typed CLI flags → --paramsJson → overrides.params
4851
+ *
4852
+ * Undefined values are dropped so defaults declared in `defs` aren't
4853
+ * overwritten by missing-flag noise. Returns the merged object; the
4854
+ * caller is responsible for validation and throwing `ParamError`.
4855
+ *
4856
+ * @param {object} context
4857
+ * @param {string} moduleName Namespace for `--showUsedParams` grouping.
4858
+ * @param {Record<string, string>} defs Param defs in `getAllForModule` syntax.
4859
+ * @param {Record<string, unknown>} [overrides] As passed to `resolveCustomParams`.
4860
+ * @returns {Record<string, unknown>}
4861
+ */
4862
+ static _mergeTypedParams(context, moduleName, defs2, overrides = {}) {
4863
+ const fullDefs = { ...defs2, paramsJson: "string" };
4864
+ const cliRaw = context.params.getAllForModule(moduleName, fullDefs);
4865
+ const fromJson = parseParamsJson(cliRaw.paramsJson) ?? {};
4866
+ const fromCli = {};
4867
+ for (const [k, v] of Object.entries(cliRaw)) {
4868
+ if (k === "paramsJson") continue;
4869
+ if (v !== void 0 && v !== null) fromCli[k] = v;
4870
+ }
4871
+ const fromOverride = pickParamsObject(overrides) ?? {};
4872
+ return { ...fromCli, ...fromJson, ...fromOverride };
4873
+ }
4548
4874
  };
4875
+ function emptyToUndef(s) {
4876
+ if (s === void 0 || s === null) return void 0;
4877
+ if (typeof s !== "string") return s;
4878
+ const t = s.trim();
4879
+ return t.length ? t : void 0;
4880
+ }
4881
+ function parseParamsJson(raw) {
4882
+ if (raw == null) return null;
4883
+ const t = String(raw).trim();
4884
+ if (!t) return null;
4885
+ let parsed;
4886
+ try {
4887
+ parsed = JSON.parse(t);
4888
+ } catch (e) {
4889
+ throw new ParamError(`--paramsJson: not valid JSON: ${e?.message ?? String(e)}`);
4890
+ }
4891
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4892
+ throw new ParamError("--paramsJson must be a JSON object");
4893
+ }
4894
+ return parsed;
4895
+ }
4896
+ function pickParamsObject(overrides) {
4897
+ const p = overrides?.params;
4898
+ if (p && typeof p === "object" && !Array.isArray(p)) return p;
4899
+ return void 0;
4900
+ }
4549
4901
 
4550
- // src/tasks/coreTasks/TaskPing.ts
4551
- var TaskPing = class extends TaskMaster {
4902
+ // src/tasks/coreTasks/TaskPing.js
4903
+ var TaskPing = class extends AbstractTask {
4904
+ /** Default-wait so `send-task --name=ping` prints a result without `--wait`. */
4905
+ static defaultWaitForResult = true;
4906
+ /** Ping takes no params. */
4907
+ static async resolveCustomParams() {
4908
+ return null;
4909
+ }
4910
+ /**
4911
+ * @returns {Promise<{ success: true, results: "pong" }>}
4912
+ */
4552
4913
  async run() {
4553
4914
  this.context.logger.info?.(`[TaskPing] pong (${this.task.id})`);
4554
4915
  return { success: true, results: "pong" };
4555
4916
  }
4556
4917
  };
4557
4918
 
4558
- // src/tasks/coreTasks/TaskSampleProcess.ts
4559
- var TaskSampleProcess = class extends TaskMaster {
4560
- stopRequested = false;
4561
- stopAllowanceMs = 0;
4562
- stopDecisionLogged = false;
4919
+ // src/tasks/coreTasks/TaskSampleProcess.js
4920
+ var TaskSampleProcess = class extends AbstractTask {
4921
+ /**
4922
+ * @param {object} context
4923
+ * @param {Record<string, unknown>} [overrides]
4924
+ * @returns {Promise<{ total: number, delay: number, name?: string }>}
4925
+ */
4926
+ static async resolveCustomParams(context, overrides = {}) {
4927
+ const merged = AbstractTask._mergeTypedParams(context, "task-sample-process", {
4928
+ total: "number default 10",
4929
+ delay: "number default 1000",
4930
+ name: "string"
4931
+ }, overrides);
4932
+ const total = Number(merged.total);
4933
+ const delay = Number(merged.delay);
4934
+ if (!Number.isInteger(total) || total <= 0) {
4935
+ throw new ParamError(`sampleProcess: param "total" must be a positive integer (got ${JSON.stringify(merged.total)})`);
4936
+ }
4937
+ if (!Number.isInteger(delay) || delay < 0) {
4938
+ throw new ParamError(`sampleProcess: param "delay" must be an integer >= 0 (got ${JSON.stringify(merged.delay)})`);
4939
+ }
4940
+ const out = { total, delay };
4941
+ if (typeof merged.name === "string" && merged.name.trim()) {
4942
+ out.name = merged.name.trim();
4943
+ }
4944
+ return out;
4945
+ }
4946
+ /**
4947
+ * @param {object} context
4948
+ * @param {object} task
4949
+ */
4950
+ constructor(context, task) {
4951
+ super(context, task);
4952
+ this.stopRequested = false;
4953
+ this.stopAllowanceMs = 0;
4954
+ this.stopDecisionLogged = false;
4955
+ }
4956
+ /**
4957
+ * Runner-facing stop signal. Records the allowance window so the main loop
4958
+ * can decide per-iteration whether to finish or abort early.
4959
+ *
4960
+ * @param {number} allowanceMs
4961
+ */
4563
4962
  requestStop(allowanceMs) {
4564
4963
  this.stopRequested = true;
4565
4964
  this.stopAllowanceMs = Number.isFinite(allowanceMs) && allowanceMs > 0 ? allowanceMs : 0;
@@ -4567,6 +4966,14 @@ var TaskSampleProcess = class extends TaskMaster {
4567
4966
  `[TaskSampleProcess] stop signal received (${this.task.id}), allowanceMs=${this.stopAllowanceMs}`
4568
4967
  );
4569
4968
  }
4969
+ /**
4970
+ * Iterate `total` times, sleeping `delay` ms between ticks and reporting
4971
+ * progress every iteration. Validates params up front; invalid values short-
4972
+ * circuit to a structured failure without starting the loop.
4973
+ *
4974
+ * @param {(progress: object) => Promise<void>} reportProgress
4975
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4976
+ */
4570
4977
  async run(reportProgress) {
4571
4978
  const totalRaw = this.task?.params?.total ?? 10;
4572
4979
  const delayRaw = this.task?.params?.delay ?? 1e3;
@@ -4648,7 +5055,7 @@ var TaskSampleProcess = class extends TaskMaster {
4648
5055
  }
4649
5056
  };
4650
5057
 
4651
- // src/tasks/coreTasks/TaskShellCommand.ts
5058
+ // src/tasks/coreTasks/TaskShellCommand.js
4652
5059
  import { spawn } from "child_process";
4653
5060
  function runShellCommand(command, cwd) {
4654
5061
  return new Promise((resolve2, reject) => {
@@ -4678,7 +5085,27 @@ function runShellCommand(command, cwd) {
4678
5085
  });
4679
5086
  });
4680
5087
  }
4681
- var TaskShellCommand = class extends TaskMaster {
5088
+ var TaskShellCommand = class extends AbstractTask {
5089
+ /**
5090
+ * @param {object} context
5091
+ * @param {Record<string, unknown>} [overrides]
5092
+ * @returns {Promise<{ command: string, cwd?: string }>}
5093
+ */
5094
+ static async resolveCustomParams(context, overrides = {}) {
5095
+ const merged = AbstractTask._mergeTypedParams(context, "task-shell", {
5096
+ command: "string",
5097
+ cwd: "string"
5098
+ }, overrides);
5099
+ const command = typeof merged.command === "string" ? merged.command.trim() : "";
5100
+ if (!command) {
5101
+ throw new ParamError('shellCommand: param "command" must be a non-empty string');
5102
+ }
5103
+ const cwd = typeof merged.cwd === "string" && merged.cwd.trim() ? merged.cwd.trim() : null;
5104
+ return cwd ? { command, cwd } : { command };
5105
+ }
5106
+ /**
5107
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5108
+ */
4682
5109
  async run() {
4683
5110
  const params = this.task?.params;
4684
5111
  const commandRaw = typeof params === "string" ? params : params?.command;
@@ -4727,7 +5154,7 @@ var TaskShellCommand = class extends TaskMaster {
4727
5154
  }
4728
5155
  };
4729
5156
 
4730
- // src/tasks/coreTasks/TaskSystemInfo.ts
5157
+ // src/tasks/coreTasks/TaskSystemInfo.js
4731
5158
  import os2 from "os";
4732
5159
  import fs4 from "fs/promises";
4733
5160
  function toGb(valueBytes) {
@@ -4747,7 +5174,16 @@ async function getDiskStats() {
4747
5174
  free: toGb(free)
4748
5175
  };
4749
5176
  }
4750
- var TaskSystemInfo = class extends TaskMaster {
5177
+ var TaskSystemInfo = class extends AbstractTask {
5178
+ /** Same UX expectation as `ping` — short probe, print the result. */
5179
+ static defaultWaitForResult = true;
5180
+ /** systemInfo takes no params. */
5181
+ static async resolveCustomParams() {
5182
+ return null;
5183
+ }
5184
+ /**
5185
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5186
+ */
4751
5187
  async run() {
4752
5188
  try {
4753
5189
  const totalMemory = os2.totalmem();
@@ -4799,8 +5235,31 @@ var TaskSystemInfo = class extends TaskMaster {
4799
5235
  }
4800
5236
  };
4801
5237
 
4802
- // src/tasks/coreTasks/TaskSumAB.ts
4803
- var TaskSumAB = class extends TaskMaster {
5238
+ // src/tasks/coreTasks/TaskSumAB.js
5239
+ var TaskSumAB = class extends AbstractTask {
5240
+ /** Short, deterministic — wait by default so callers see the sum. */
5241
+ static defaultWaitForResult = true;
5242
+ /**
5243
+ * @param {object} context
5244
+ * @param {Record<string, unknown>} [overrides]
5245
+ * @returns {Promise<{ a: number, b: number }>}
5246
+ */
5247
+ static async resolveCustomParams(context, overrides = {}) {
5248
+ const merged = AbstractTask._mergeTypedParams(context, "task-sumab", {
5249
+ a: "number",
5250
+ b: "number"
5251
+ }, overrides);
5252
+ if (typeof merged.a !== "number" || Number.isNaN(merged.a)) {
5253
+ throw new ParamError(`taskSumAB: param "a" must be a valid number (got ${JSON.stringify(merged.a)})`);
5254
+ }
5255
+ if (typeof merged.b !== "number" || Number.isNaN(merged.b)) {
5256
+ throw new ParamError(`taskSumAB: param "b" must be a valid number (got ${JSON.stringify(merged.b)})`);
5257
+ }
5258
+ return { a: merged.a, b: merged.b };
5259
+ }
5260
+ /**
5261
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5262
+ */
4804
5263
  async run() {
4805
5264
  const a = this.task?.params?.a;
4806
5265
  const b = this.task?.params?.b;
@@ -4831,8 +5290,46 @@ var TaskSumAB = class extends TaskMaster {
4831
5290
  }
4832
5291
  };
4833
5292
 
4834
- // src/tasks/coreTasks/TaskStopRunner.ts
4835
- var TaskStopRunner = class extends TaskMaster {
5293
+ // src/tasks/coreTasks/TaskStopRunner.js
5294
+ var TaskStopRunner = class extends AbstractTask {
5295
+ /**
5296
+ * Stop tasks must target a concrete instance — without `serviceName` the
5297
+ * row would race against any worker on the queue. Layered on top of the
5298
+ * envelope built by {@link AbstractTask.resolveParams}.
5299
+ *
5300
+ * @param {object} context
5301
+ * @param {Record<string, unknown>} [overrides]
5302
+ * @returns {Promise<object>}
5303
+ */
5304
+ static async resolveParams(context, overrides = {}) {
5305
+ const main = await super.resolveParams(context, overrides);
5306
+ if (!main.serviceName) {
5307
+ throw new ParamError(
5308
+ "stop/stopRunner requires --serviceName (registry instance name; optional --serviceGroup, --instanceNumber, --serverName to narrow targeting)"
5309
+ );
5310
+ }
5311
+ return main;
5312
+ }
5313
+ /**
5314
+ * @param {object} context
5315
+ * @param {Record<string, unknown>} [overrides]
5316
+ * @returns {Promise<{ allowanceMs: number }>}
5317
+ */
5318
+ static async resolveCustomParams(context, overrides = {}) {
5319
+ const merged = AbstractTask._mergeTypedParams(context, "task-stop", {
5320
+ allowanceMs: "number default 5000"
5321
+ }, overrides);
5322
+ const allowanceMs = Number(merged.allowanceMs);
5323
+ if (!Number.isFinite(allowanceMs) || allowanceMs < 0) {
5324
+ throw new ParamError(
5325
+ `stopRunner: allowanceMs must be a non-negative number (got ${JSON.stringify(merged.allowanceMs)})`
5326
+ );
5327
+ }
5328
+ return { allowanceMs };
5329
+ }
5330
+ /**
5331
+ * @returns {Promise<{ success: true, results: { stopRunner: true, allowanceMs: number, message: string } }>}
5332
+ */
4836
5333
  async run() {
4837
5334
  const allowanceMs = Number(this.task?.params?.allowanceMs ?? 5e3);
4838
5335
  this.context.logger.warn?.(`[TaskStopRunner] stop requested (allowanceMs=${allowanceMs})`);
@@ -4847,42 +5344,200 @@ var TaskStopRunner = class extends TaskMaster {
4847
5344
  }
4848
5345
  };
4849
5346
 
4850
- // src/tasks/TasksRegistry.ts
5347
+ // src/tasks/coreTasks/TaskGetLogs.js
5348
+ var TaskGetLogs = class extends AbstractTask {
5349
+ /**
5350
+ * @param {object} context
5351
+ * @param {Record<string, unknown>} [overrides]
5352
+ * @returns {Promise<{ source: string, resource: string, tail: number, afterTs?: string }>}
5353
+ */
5354
+ static async resolveCustomParams(context, overrides = {}) {
5355
+ const merged = AbstractTask._mergeTypedParams(context, "task-get-logs", {
5356
+ source: "string",
5357
+ resource: "string",
5358
+ tail: "number default 100",
5359
+ afterTs: "string"
5360
+ }, overrides);
5361
+ const source = typeof merged.source === "string" ? merged.source.trim() : "";
5362
+ const resource = typeof merged.resource === "string" ? merged.resource.trim() : "";
5363
+ if (!source) throw new ParamError('getLogs: param "source" is required');
5364
+ if (!resource) throw new ParamError('getLogs: param "resource" is required');
5365
+ let tail = Number(merged.tail);
5366
+ if (!Number.isFinite(tail) || tail < 1) tail = 100;
5367
+ tail = Math.min(1e4, Math.max(1, Math.floor(tail)));
5368
+ const out = { source, resource, tail };
5369
+ if (typeof merged.afterTs === "string" && merged.afterTs.trim()) {
5370
+ out.afterTs = merged.afterTs.trim();
5371
+ }
5372
+ return out;
5373
+ }
5374
+ /**
5375
+ * @param {unknown} _reportProgress Unused (single-shot read, no progress events).
5376
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5377
+ */
5378
+ async run(_reportProgress) {
5379
+ const p = this.task.params ?? {};
5380
+ const source = String(p.source ?? "").trim();
5381
+ const resource = String(p.resource ?? "").trim();
5382
+ const tail = Math.max(1, Math.min(1e4, Number(p.tail) > 0 ? Number(p.tail) : 100));
5383
+ const afterTs = p.afterTs != null && String(p.afterTs).trim() ? String(p.afterTs).trim() : null;
5384
+ if (!source || !resource) {
5385
+ return {
5386
+ success: false,
5387
+ results: { error: 'getLogs requires params "source" and "resource"' }
5388
+ };
5389
+ }
5390
+ try {
5391
+ const { records, latestTs } = await readTaskIpcLogsSnapshot(this.context, {
5392
+ source,
5393
+ resource,
5394
+ tail,
5395
+ afterTs
5396
+ });
5397
+ return {
5398
+ success: true,
5399
+ results: { records, latestTs, source, resource }
5400
+ };
5401
+ } catch (e) {
5402
+ return {
5403
+ success: false,
5404
+ results: { error: e?.message ?? String(e) }
5405
+ };
5406
+ }
5407
+ }
5408
+ };
5409
+
5410
+ // src/tasks/TasksRegistry.js
4851
5411
  var TasksRegistry = class _TasksRegistry {
4852
- map = {};
5412
+ /**
5413
+ * @param {Record<string, Function>} [initial] Optional seed entries to copy in.
5414
+ */
4853
5415
  constructor(initial) {
5416
+ this.map = {};
4854
5417
  if (initial) {
4855
5418
  this.addMany(initial);
4856
5419
  }
4857
5420
  }
5421
+ /**
5422
+ * Build a registry pre-populated with every core task plus legacy aliases.
5423
+ * Prefer this over `new TasksRegistry()` for anything that wants `ping`/`stop`/etc.
5424
+ *
5425
+ * @returns {TasksRegistry}
5426
+ */
4858
5427
  static withCoreTasks() {
4859
- return new _TasksRegistry().add("ping", TaskPing).add("sampleProcess", TaskSampleProcess).add("shellCommand", TaskShellCommand).add("systemInfo", TaskSystemInfo).add("taskSumAB", TaskSumAB).add("stopRunner", TaskStopRunner).add("stop", TaskStopRunner);
5428
+ 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);
4860
5429
  }
5430
+ /**
5431
+ * Register a single task class under a name. Overwrites any previous entry.
5432
+ *
5433
+ * @param {string} taskName
5434
+ * @param {Function} taskClass Subclass of `AbstractTask`.
5435
+ * @returns {this}
5436
+ */
4861
5437
  add(taskName, taskClass) {
4862
5438
  this.map[taskName] = taskClass;
4863
5439
  return this;
4864
5440
  }
5441
+ /**
5442
+ * Bulk-register a name → class map. Later calls override earlier ones.
5443
+ *
5444
+ * @param {Record<string, Function>} entries
5445
+ * @returns {this}
5446
+ */
4865
5447
  addMany(entries) {
4866
5448
  for (const [name, klass] of Object.entries(entries)) {
4867
5449
  this.add(name, klass);
4868
5450
  }
4869
5451
  return this;
4870
5452
  }
5453
+ /**
5454
+ * Look up a task class by name. Returns `undefined` when the name is unknown;
5455
+ * the runner treats that as "some other worker may handle this" and skips.
5456
+ *
5457
+ * @param {string} taskName
5458
+ * @returns {Function | undefined}
5459
+ */
4871
5460
  get(taskName) {
4872
5461
  return this.map[taskName];
4873
5462
  }
5463
+ /**
5464
+ * Strict variant of {@link get}: throws {@link ParamError} (with the list
5465
+ * of supported names) when `taskName` is unknown. Use from enqueuer code
5466
+ * paths where an unknown name is a hard CLI/programmer error.
5467
+ *
5468
+ * @param {string} taskName
5469
+ * @returns {Function}
5470
+ */
5471
+ requireClass(taskName) {
5472
+ const TaskClass = taskName ? this.map[taskName] : void 0;
5473
+ if (!TaskClass) {
5474
+ const supported = this.listSupportedTasks().join(", ") || "(none)";
5475
+ throw new ParamError(
5476
+ `Unknown task "${taskName ?? ""}". Supported on this registry: ${supported}`
5477
+ );
5478
+ }
5479
+ return TaskClass;
5480
+ }
5481
+ /**
5482
+ * Dispatcher used by `send-task` / programmatic enqueuers: pick `name`
5483
+ * from `overrides` or `context.params`, look up the class, and delegate
5484
+ * to its static {@link AbstractTask.resolveParams} with `name` seeded into
5485
+ * the overrides. The returned object is shaped for {@link enqueueTask}.
5486
+ *
5487
+ * Validation failures (unknown task, missing required custom params, etc.)
5488
+ * surface as {@link ParamError} so the caller aborts cleanly before any
5489
+ * row is inserted.
5490
+ *
5491
+ * @param {object} context
5492
+ * @param {Record<string, unknown>} [overrides]
5493
+ * @returns {Promise<object>}
5494
+ */
5495
+ async resolveTaskParams(context, overrides = {}) {
5496
+ const overrideName = typeof overrides.name === "string" ? overrides.name.trim() : overrides.name;
5497
+ const fromCli = context.params.get("name", "string");
5498
+ const cliName = typeof fromCli === "string" ? fromCli.trim() : fromCli;
5499
+ const name = overrideName || cliName;
5500
+ if (!name) {
5501
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
5502
+ }
5503
+ const TaskClass = this.requireClass(name);
5504
+ return TaskClass.resolveParams(context, { ...overrides, name });
5505
+ }
5506
+ /**
5507
+ * Names of every registered task, sorted alphabetically (useful for CLI output
5508
+ * and allowlist sanity checks).
5509
+ *
5510
+ * @returns {string[]}
5511
+ */
4874
5512
  listSupportedTasks() {
4875
5513
  return Object.keys(this.map).sort();
4876
5514
  }
5515
+ /**
5516
+ * Shallow copy of the internal map, for handing to `addMany` on another registry
5517
+ * or for serialization.
5518
+ *
5519
+ * @returns {Record<string, Function>}
5520
+ */
4877
5521
  toObject() {
4878
5522
  return { ...this.map };
4879
5523
  }
4880
5524
  };
4881
5525
 
4882
- // src/tasks/taskScriptRunner.ts
5526
+ // src/tasks/serviceTaskAllowlist.js
5527
+ function normalizeAllowedTasks(value) {
5528
+ if (!value) return void 0;
5529
+ if (Array.isArray(value)) {
5530
+ const out2 = value.map((v) => String(v).trim()).filter(Boolean);
5531
+ return out2.length ? out2 : void 0;
5532
+ }
5533
+ const out = String(value).split(",").map((v) => v.trim()).filter(Boolean);
5534
+ return out.length ? out : void 0;
5535
+ }
5536
+
5537
+ // src/tasks/taskScriptRunner.js
4883
5538
  import { spawn as spawn2 } from "child_process";
4884
5539
 
4885
- // src/tasks/index.ts
5540
+ // src/tasks/index.js
4886
5541
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
4887
5542
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
4888
5543
  function getDb3(context) {
@@ -4897,15 +5552,6 @@ function normalizeRegistry(registry) {
4897
5552
  if (registry instanceof TasksRegistry) return registry;
4898
5553
  return new TasksRegistry().addMany(registry);
4899
5554
  }
4900
- function normalizeAllowedTasks(value) {
4901
- if (!value) return void 0;
4902
- if (Array.isArray(value)) {
4903
- const out2 = value.map((v) => String(v).trim()).filter(Boolean);
4904
- return out2.length ? out2 : void 0;
4905
- }
4906
- const out = String(value).split(",").map((v) => v.trim()).filter(Boolean);
4907
- return out.length ? out : void 0;
4908
- }
4909
5555
  async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs) {
4910
5556
  context.logger.warn?.(`[tasks] signaling ${runningTaskInstances.size} running task(s) to stop`);
4911
5557
  for (const [, taskInstance] of runningTaskInstances) {
@@ -4921,18 +5567,20 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
4921
5567
  }
4922
5568
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
4923
5569
  const db = getDb3(context);
4924
- const taskName = row.task;
5570
+ const taskName = row.name;
4925
5571
  const TaskClass = registry.get(taskName);
4926
- const { paused_at: _pausedAt, ...rowForHistory } = row;
4927
5572
  if (!TaskClass) {
4928
5573
  const err = { message: `Unknown task "${taskName}"` };
4929
- await db(historyTable).insert({
4930
- ...rowForHistory,
4931
- completed_at: /* @__PURE__ */ new Date(),
4932
- success: false,
4933
- params: toJsonColumn(row.params),
4934
- results: toJsonColumn(err)
4935
- });
5574
+ await db(historyTable).insert(
5575
+ taskHistoryInsertFromQueueRow(row, {
5576
+ completed_at: /* @__PURE__ */ new Date(),
5577
+ success: false,
5578
+ status: "failed",
5579
+ status_changed_at: db.fn.now(),
5580
+ params: toJsonColumn(row.params),
5581
+ results: toJsonColumn(err)
5582
+ })
5583
+ );
4936
5584
  if (row.schedule) {
4937
5585
  await db(tasksTable).where({ id: row.id }).update({
4938
5586
  started_at: null,
@@ -4940,7 +5588,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4940
5588
  success: false,
4941
5589
  results: toJsonColumn(err),
4942
5590
  past_due: null,
4943
- paused_at: db.fn.now(),
5591
+ status: "paused",
5592
+ status_changed_at: db.fn.now(),
4944
5593
  progress: LOCKED_BY_ERROR_MESSAGE
4945
5594
  });
4946
5595
  } else {
@@ -4967,13 +5616,16 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4967
5616
  } finally {
4968
5617
  runningTaskInstances.delete(row.id);
4969
5618
  }
4970
- await db(historyTable).insert({
4971
- ...rowForHistory,
4972
- completed_at: /* @__PURE__ */ new Date(),
4973
- success,
4974
- params: toJsonColumn(row.params),
4975
- results: toJsonColumn(results)
4976
- });
5619
+ await db(historyTable).insert(
5620
+ taskHistoryInsertFromQueueRow(row, {
5621
+ completed_at: /* @__PURE__ */ new Date(),
5622
+ success,
5623
+ status: success ? "completed" : "failed",
5624
+ status_changed_at: db.fn.now(),
5625
+ params: toJsonColumn(row.params),
5626
+ results: toJsonColumn(results)
5627
+ })
5628
+ );
4977
5629
  if (!success) {
4978
5630
  const dbName = String(context?.params?.get?.("dbName") || "local");
4979
5631
  const tableName = String(context?.params?.get?.("table") || "tasks");
@@ -4988,19 +5640,32 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4988
5640
  const rerunCommand = results && typeof results === "object" && results.rerunCommand ? results.rerunCommand : fallbackRecoverCommand;
4989
5641
  appendTaskIpcLog(context, row, {
4990
5642
  level: "error",
4991
- message: `[tasks] task failed: ${row.task} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
5643
+ message: `[tasks] task failed: ${row.name} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
4992
5644
  details: results
4993
5645
  });
4994
5646
  }
4995
5647
  if (row.schedule) {
4996
5648
  if (success) {
5649
+ let nextRunAt = null;
5650
+ try {
5651
+ nextRunAt = nextTimeMatch(row.schedule, /* @__PURE__ */ new Date());
5652
+ } catch (e) {
5653
+ context.logger?.warn?.(`[tasks] nextTimeMatch after success for task ${row.id}: ${e?.message ?? String(e)}`);
5654
+ }
4997
5655
  await db(tasksTable).where({ id: row.id }).update({
4998
5656
  started_at: null,
4999
5657
  completed_at: /* @__PURE__ */ new Date(),
5000
5658
  success,
5001
5659
  results: toJsonColumn(results),
5002
5660
  progress: null,
5003
- past_due: null
5661
+ past_due: null,
5662
+ status: "idle",
5663
+ status_changed_at: db.fn.now(),
5664
+ next_run_at: nextRunAt,
5665
+ // Claim overwrites these; clear so idle rows stay “any worker” (see claimNextRunnableTask).
5666
+ service_name: null,
5667
+ server_name: null,
5668
+ instance_number: null
5004
5669
  });
5005
5670
  } else {
5006
5671
  await db(tasksTable).where({ id: row.id }).update({
@@ -5008,7 +5673,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5008
5673
  completed_at: /* @__PURE__ */ new Date(),
5009
5674
  success,
5010
5675
  results: toJsonColumn(results),
5011
- paused_at: db.fn.now(),
5676
+ status: "paused",
5677
+ status_changed_at: db.fn.now(),
5012
5678
  progress: LOCKED_BY_ERROR_MESSAGE,
5013
5679
  past_due: null
5014
5680
  });
@@ -5020,58 +5686,93 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5020
5686
  const stopAllowanceMs = stopRunnerRequested ? Number(results.allowanceMs ?? 5e3) : 0;
5021
5687
  return { stopRunnerRequested, stopAllowanceMs };
5022
5688
  }
5023
- async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
5689
+ function shuffleTaskRowsInPlace(rows) {
5690
+ for (let i = rows.length - 1; i > 0; i--) {
5691
+ const j = Math.floor(Math.random() * (i + 1));
5692
+ const t = rows[i];
5693
+ rows[i] = rows[j];
5694
+ rows[j] = t;
5695
+ }
5696
+ }
5697
+ async function claimNextRunnableTask(context, tasksTable, serviceGroup, registry, scanLimit, taskNames, runnerIdentity) {
5024
5698
  const db = getDb3(context);
5025
- 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);
5699
+ let query = db(tasksTable).where({ status: "idle" }).where(function() {
5700
+ this.whereNull("service_group").orWhere({ service_group: serviceGroup });
5701
+ }).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);
5026
5702
  if (taskNames && taskNames.length > 0) {
5027
- query = query.whereIn("task", taskNames);
5703
+ query = query.whereIn("name", taskNames);
5704
+ }
5705
+ if (runnerIdentity) {
5706
+ query = query.where(function() {
5707
+ this.whereNull("service_name").orWhere({ service_name: runnerIdentity.service_name });
5708
+ }).where(function() {
5709
+ this.whereNull("instance_number").orWhere({ instance_number: runnerIdentity.instance_number });
5710
+ }).where(function() {
5711
+ this.whereNull("server_name").orWhere({ server_name: runnerIdentity.server_name });
5712
+ });
5713
+ } else {
5714
+ query = query.whereNull("service_name").whereNull("instance_number").whereNull("server_name");
5028
5715
  }
5029
5716
  const candidates = await query;
5717
+ shuffleTaskRowsInPlace(candidates);
5030
5718
  for (const row of candidates) {
5031
5719
  if (!row.past_due && row.schedule && !timeMatcher(row.schedule)) {
5032
5720
  continue;
5033
5721
  }
5034
- const TaskClass = registry.get(row.task);
5035
- if (TaskClass) {
5036
- const taskInstance = new TaskClass(context, row);
5037
- const reason = taskInstance.cantRunReason ? await taskInstance.cantRunReason() : null;
5038
- if (reason) {
5039
- if (!row.past_due) {
5040
- await db(tasksTable).where({ id: row.id }).update({
5041
- past_due: db.fn.now(),
5042
- progress: String(reason)
5043
- });
5044
- }
5045
- continue;
5722
+ const TaskClass = registry.get(row.name);
5723
+ if (!TaskClass) {
5724
+ continue;
5725
+ }
5726
+ const taskInstance = new TaskClass(context, row);
5727
+ const reason = taskInstance.cantRunReason ? await taskInstance.cantRunReason() : null;
5728
+ if (reason) {
5729
+ if (!row.past_due) {
5730
+ await db(tasksTable).where({ id: row.id }).update({
5731
+ past_due: db.fn.now(),
5732
+ progress: String(reason)
5733
+ });
5046
5734
  }
5735
+ continue;
5736
+ }
5737
+ const claimPatch = {
5738
+ started_at: db.fn.now(),
5739
+ status: "running",
5740
+ status_changed_at: db.fn.now()
5741
+ };
5742
+ if (runnerIdentity) {
5743
+ claimPatch.service_name = runnerIdentity.service_name;
5744
+ claimPatch.server_name = runnerIdentity.server_name;
5745
+ claimPatch.instance_number = runnerIdentity.instance_number;
5047
5746
  }
5048
- const updated = await db(tasksTable).where({ id: row.id }).whereNull("started_at").whereNull("paused_at").update({ started_at: db.fn.now() }).returning("*");
5747
+ const updated = await db(tasksTable).where({ id: row.id, status: "idle" }).update(claimPatch).returning("*");
5049
5748
  const claimed = Array.isArray(updated) ? updated[0] : null;
5050
5749
  if (claimed) return claimed;
5051
5750
  }
5052
5751
  return null;
5053
5752
  }
5054
5753
  async function runTasksLoop(context, options) {
5055
- const queue = options.queue ?? "tasks";
5754
+ const queueName = options.queueName ?? "tasks";
5056
5755
  const target = options.target;
5057
5756
  const pollMs = options.pollMs ?? 1e3;
5058
- const maxParallel = options.maxParallel ?? 1;
5757
+ const claimJitterMs = options.claimJitterMs ?? 0;
5758
+ const maxParallel = options.maxParallel ?? 32;
5059
5759
  const scanLimit = options.scanLimit ?? 100;
5060
5760
  const allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5061
5761
  const registry = normalizeRegistry(options.registry);
5062
- const { tasksTable, historyTable } = queueToTableNames(queue);
5762
+ const { tasksTable, historyTable } = queueToTableNames(queueName);
5063
5763
  if (!target) throw new Error("runTasksLoop: target is required");
5764
+ context.tasksQueueName = queueName;
5064
5765
  const runningPromises = /* @__PURE__ */ new Set();
5065
5766
  const runningTaskInstances = /* @__PURE__ */ new Map();
5066
5767
  let runningStopControlPromise = null;
5067
5768
  let stopRequested = false;
5068
5769
  let stopAllowanceMs = 5e3;
5069
- context.__tasksRunnerStop = false;
5770
+ context.tasksRunnerStop = false;
5070
5771
  let registryReg = null;
5071
5772
  let registryInterval = null;
5773
+ let runnerIdentity = null;
5072
5774
  const hbGroup = options.runnerServiceGroup?.trim();
5073
5775
  if (hbGroup) {
5074
- const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
5075
5776
  const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
5076
5777
  const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
5077
5778
  const defaultMeta = {
@@ -5079,16 +5780,21 @@ async function runTasksLoop(context, options) {
5079
5780
  allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
5080
5781
  };
5081
5782
  registryReg = await registerInServicesRegistry(context, {
5082
- queue,
5783
+ queueName,
5083
5784
  target,
5084
5785
  serviceGroup: hbGroup,
5085
5786
  serviceName: options.runnerServiceName,
5086
- identityDir,
5787
+ instanceNumber: options.runnerInstanceNumber,
5087
5788
  staleMs,
5088
5789
  groupMaxInstances: options.runnerGroupMaxInstances,
5089
5790
  enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
5090
5791
  metadata: options.runnerMetadata ?? defaultMeta
5091
5792
  });
5793
+ runnerIdentity = {
5794
+ service_name: registryReg.serviceName,
5795
+ server_name: os3.hostname(),
5796
+ instance_number: registryReg.instanceNumber
5797
+ };
5092
5798
  registryInterval = setInterval(() => {
5093
5799
  void touchServicesRegistry(context, registryReg).catch((err) => {
5094
5800
  context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
@@ -5096,7 +5802,7 @@ async function runTasksLoop(context, options) {
5096
5802
  }, hbIntervalMs);
5097
5803
  }
5098
5804
  try {
5099
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
5805
+ while (!context.isStop() && !stopRequested && context.tasksRunnerStop !== true) {
5100
5806
  if (!runningStopControlPromise) {
5101
5807
  const claimedStopTask = await claimNextRunnableTask(
5102
5808
  context,
@@ -5104,7 +5810,8 @@ async function runTasksLoop(context, options) {
5104
5810
  target,
5105
5811
  registry,
5106
5812
  10,
5107
- ["stopRunner", "stop"]
5813
+ ["stopRunner", "stop"],
5814
+ runnerIdentity
5108
5815
  );
5109
5816
  if (claimedStopTask) {
5110
5817
  runningStopControlPromise = executeClaimedTask(
@@ -5118,7 +5825,7 @@ async function runTasksLoop(context, options) {
5118
5825
  if (outcome.stopRunnerRequested && !stopRequested) {
5119
5826
  stopRequested = true;
5120
5827
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5121
- context.__tasksRunnerStop = true;
5828
+ context.tasksRunnerStop = true;
5122
5829
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5123
5830
  }
5124
5831
  }).finally(() => {
@@ -5126,6 +5833,9 @@ async function runTasksLoop(context, options) {
5126
5833
  });
5127
5834
  }
5128
5835
  }
5836
+ if (claimJitterMs > 0) {
5837
+ await sleepMs(Math.floor(Math.random() * (claimJitterMs + 1)));
5838
+ }
5129
5839
  while (runningPromises.size < maxParallel) {
5130
5840
  const claimed = await claimNextRunnableTask(
5131
5841
  context,
@@ -5133,14 +5843,15 @@ async function runTasksLoop(context, options) {
5133
5843
  target,
5134
5844
  registry,
5135
5845
  scanLimit,
5136
- allowedTasks
5846
+ allowedTasks,
5847
+ runnerIdentity
5137
5848
  );
5138
5849
  if (!claimed) break;
5139
5850
  const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
5140
5851
  if (outcome.stopRunnerRequested && !stopRequested) {
5141
5852
  stopRequested = true;
5142
5853
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5143
- context.__tasksRunnerStop = true;
5854
+ context.tasksRunnerStop = true;
5144
5855
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5145
5856
  }
5146
5857
  }).finally(() => {
@@ -5148,7 +5859,16 @@ async function runTasksLoop(context, options) {
5148
5859
  });
5149
5860
  runningPromises.add(p);
5150
5861
  }
5151
- await sleepMs(pollMs);
5862
+ const wakePromises = [...runningPromises];
5863
+ if (runningStopControlPromise) {
5864
+ wakePromises.push(runningStopControlPromise);
5865
+ }
5866
+ if (wakePromises.length === 0) {
5867
+ await sleepMs(pollMs);
5868
+ } else {
5869
+ const safe = wakePromises.map((p) => p.catch(() => void 0));
5870
+ await Promise.race([sleepMs(pollMs), Promise.race(safe)]);
5871
+ }
5152
5872
  }
5153
5873
  if (context.isStop() && !stopRequested) {
5154
5874
  await signalRunningTasksStop(context, runningTaskInstances, 5e3);
@@ -5183,71 +5903,88 @@ async function runTasksLoop(context, options) {
5183
5903
  }
5184
5904
  }
5185
5905
  var TasksManager = class _TasksManager {
5186
- context;
5187
- queue;
5188
- target;
5189
- recreateTaskTables;
5190
- pollMs;
5191
- maxParallel;
5192
- scanLimit;
5193
- allowedTasks;
5194
- registry;
5195
- runnerServiceGroup;
5196
- runnerServiceName;
5197
- runnerIdentityDir;
5198
- runnerHeartbeatIntervalMs;
5199
- runnerHeartbeatStaleMs;
5200
- runnerGroupMaxInstances;
5201
- runnerEnforceMaxInstances;
5202
- runnerMetadata;
5906
+ /**
5907
+ * @param {object} context
5908
+ * @param {{
5909
+ * queueName?: string,
5910
+ * target?: string,
5911
+ * recreateTaskTables?: boolean,
5912
+ * pollMs?: number,
5913
+ * claimJitterMs?: number,
5914
+ * maxParallel?: number,
5915
+ * scanLimit?: number,
5916
+ * allowedTasks?: string | string[],
5917
+ * registry?: TasksRegistry | Record<string, Function>,
5918
+ * runnerServiceGroup?: string,
5919
+ * runnerServiceName?: string,
5920
+ * runnerInstanceNumber?: number,
5921
+ * runnerHeartbeatIntervalMs?: number,
5922
+ * runnerHeartbeatStaleMs?: number,
5923
+ * runnerGroupMaxInstances?: number,
5924
+ * runnerEnforceMaxInstances?: boolean,
5925
+ * runnerMetadata?: Record<string, unknown>,
5926
+ * }} [options]
5927
+ */
5203
5928
  constructor(context, options = {}) {
5204
5929
  this.context = context;
5205
- this.queue = options.queue ?? "tasks";
5930
+ this.queueName = options.queueName ?? "tasks";
5206
5931
  this.target = options.target ?? "localRunner";
5207
5932
  this.recreateTaskTables = options.recreateTaskTables ?? false;
5208
5933
  this.pollMs = options.pollMs ?? 1e3;
5934
+ this.claimJitterMs = options.claimJitterMs ?? 0;
5209
5935
  this.maxParallel = options.maxParallel ?? 1;
5210
5936
  this.scanLimit = options.scanLimit ?? 100;
5211
5937
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5212
5938
  this.registry = normalizeRegistry(options.registry);
5213
5939
  this.runnerServiceGroup = options.runnerServiceGroup;
5214
5940
  this.runnerServiceName = options.runnerServiceName;
5215
- this.runnerIdentityDir = options.runnerIdentityDir;
5941
+ this.runnerInstanceNumber = options.runnerInstanceNumber;
5216
5942
  this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
5217
5943
  this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
5218
5944
  this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
5219
5945
  this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
5220
5946
  this.runnerMetadata = options.runnerMetadata;
5221
5947
  }
5948
+ /**
5949
+ * Preferred factory: reads defaults from `context.params` (module namespace
5950
+ * `"tasks"`), then overlays explicit `options`. Keeps CLI flags, env vars,
5951
+ * and inline options in one consistent resolver.
5952
+ *
5953
+ * @param {object} context
5954
+ * @param {ConstructorParameters<typeof TasksManager>[1]} [options]
5955
+ * @returns {TasksManager}
5956
+ */
5222
5957
  static init(context, options = {}) {
5223
5958
  const defs2 = {
5224
5959
  table: "string default tasks",
5225
5960
  target: "string default localRunner",
5226
5961
  recreateTaskTables: "boolean default false",
5227
5962
  pollMs: "number default 1000",
5963
+ claimJitterMs: "number default 0",
5228
5964
  maxParallel: "number default 1",
5229
5965
  scanLimit: "number default 100",
5230
5966
  allowedTasks: "string",
5231
5967
  runnerServiceGroup: "string",
5232
5968
  runnerServiceName: "string",
5233
- runnerIdentityDir: "string default ./data/runner-identities",
5969
+ runnerInstanceNumber: "number",
5234
5970
  runnerHeartbeatIntervalMs: "number default 10000",
5235
5971
  runnerHeartbeatStaleMs: "number default 45000",
5236
5972
  runnerGroupMaxInstances: "number",
5237
5973
  runnerEnforceMaxInstances: "boolean default true"
5238
5974
  };
5239
- const discovered = context.params.getAllForModule(defs2);
5975
+ const discovered = context.params.getAllForModule("tasks", defs2);
5240
5976
  const resolved = {
5241
- queue: discovered.table,
5977
+ queueName: discovered.table,
5242
5978
  target: discovered.target,
5243
5979
  recreateTaskTables: discovered.recreateTaskTables,
5244
5980
  pollMs: discovered.pollMs,
5981
+ claimJitterMs: discovered.claimJitterMs,
5245
5982
  maxParallel: discovered.maxParallel,
5246
5983
  scanLimit: discovered.scanLimit,
5247
5984
  allowedTasks: discovered.allowedTasks,
5248
5985
  runnerServiceGroup: discovered.runnerServiceGroup,
5249
5986
  runnerServiceName: discovered.runnerServiceName,
5250
- runnerIdentityDir: discovered.runnerIdentityDir,
5987
+ runnerInstanceNumber: discovered.runnerInstanceNumber,
5251
5988
  runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
5252
5989
  runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
5253
5990
  runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
@@ -5256,24 +5993,39 @@ var TasksManager = class _TasksManager {
5256
5993
  };
5257
5994
  return new _TasksManager(context, resolved);
5258
5995
  }
5996
+ /**
5997
+ * Idempotently ensure the three backing tables exist for this queue.
5998
+ *
5999
+ * @param {{ recreate?: boolean }} [options]
6000
+ * @returns {Promise<void>}
6001
+ */
5259
6002
  async ensureTaskTables(options = {}) {
5260
6003
  await ensureTaskTables(this.context, {
5261
- queue: this.queue,
6004
+ queueName: this.queueName,
5262
6005
  recreate: options.recreate ?? this.recreateTaskTables
5263
6006
  });
5264
6007
  }
6008
+ /**
6009
+ * Start the runner loop using this manager's resolved config. Per-call
6010
+ * options override the stored defaults, but `runnerMetadata` still falls
6011
+ * through when omitted.
6012
+ *
6013
+ * @param {Partial<ConstructorParameters<typeof TasksManager>[1]>} [options]
6014
+ * @returns {Promise<void>}
6015
+ */
5265
6016
  async runTasksLoop(options = {}) {
5266
6017
  await runTasksLoop(this.context, {
5267
- queue: options.queue ?? this.queue,
6018
+ queueName: options.queueName ?? this.queueName,
5268
6019
  target: options.target ?? this.target,
5269
6020
  pollMs: options.pollMs ?? this.pollMs,
6021
+ claimJitterMs: options.claimJitterMs ?? this.claimJitterMs,
5270
6022
  maxParallel: options.maxParallel ?? this.maxParallel,
5271
6023
  scanLimit: options.scanLimit ?? this.scanLimit,
5272
6024
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
5273
6025
  registry: options.registry ?? this.registry,
5274
6026
  runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
5275
6027
  runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
5276
- runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
6028
+ runnerInstanceNumber: options.runnerInstanceNumber ?? this.runnerInstanceNumber,
5277
6029
  runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
5278
6030
  runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
5279
6031
  runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
@@ -5283,9 +6035,8 @@ var TasksManager = class _TasksManager {
5283
6035
  }
5284
6036
  };
5285
6037
 
5286
- // src/scripts/cli-runner.ts
6038
+ // src/scripts/cli-runner.js
5287
6039
  var defs = {
5288
- dbName: "string default local",
5289
6040
  tasksModule: "string"
5290
6041
  };
5291
6042
  async function loadTasksModule(modulePath) {
@@ -5297,11 +6048,8 @@ async function loadTasksModule(modulePath) {
5297
6048
  return imported.tasksRegistry;
5298
6049
  }
5299
6050
  var flow = async (context) => {
5300
- const {
5301
- dbName,
5302
- tasksModule
5303
- } = context.params.getAll(defs);
5304
- const db = await dbInit(context, dbName);
6051
+ const { tasksModule } = context.params.getAll(defs);
6052
+ const db = await Db.init(context);
5305
6053
  context.db = db;
5306
6054
  const registry = new TasksRegistry().addMany(defaultTasksRegistry.toObject());
5307
6055
  if (tasksModule) {