@nmakarov/cli-toolkit 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/args.cjs +1 -4
  2. package/dist/args.cjs.map +1 -1
  3. package/dist/args.js +1 -1
  4. package/dist/args.js.map +1 -1
  5. package/dist/cli-runner.cjs +1252 -604
  6. package/dist/cli-runner.cjs.map +1 -1
  7. package/dist/cli-runner.js +1299 -650
  8. package/dist/cli-runner.js.map +1 -1
  9. package/dist/db.cjs +85 -157
  10. package/dist/db.cjs.map +1 -1
  11. package/dist/db.js +84 -150
  12. package/dist/db.js.map +1 -1
  13. package/dist/errors.cjs +2 -2
  14. package/dist/errors.cjs.map +1 -1
  15. package/dist/errors.js +2 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/filedatabase.cjs +19 -19
  18. package/dist/filedatabase.cjs.map +1 -1
  19. package/dist/filedatabase.js +19 -16
  20. package/dist/filedatabase.js.map +1 -1
  21. package/dist/http-client.cjs +9 -11
  22. package/dist/http-client.cjs.map +1 -1
  23. package/dist/http-client.js +10 -9
  24. package/dist/http-client.js.map +1 -1
  25. package/dist/http-client2.cjs +34 -37
  26. package/dist/http-client2.cjs.map +1 -1
  27. package/dist/http-client2.js +34 -34
  28. package/dist/http-client2.js.map +1 -1
  29. package/dist/index.cjs +1739 -720
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +1746 -721
  32. package/dist/index.js.map +1 -1
  33. package/dist/init.cjs +81 -68
  34. package/dist/init.cjs.map +1 -1
  35. package/dist/init.js +96 -82
  36. package/dist/init.js.map +1 -1
  37. package/dist/logger.cjs +5 -5
  38. package/dist/logger.cjs.map +1 -1
  39. package/dist/logger.js +5 -4
  40. package/dist/logger.js.map +1 -1
  41. package/dist/mock-server.cjs +21 -33
  42. package/dist/mock-server.cjs.map +1 -1
  43. package/dist/mock-server.js +21 -28
  44. package/dist/mock-server.js.map +1 -1
  45. package/dist/params.cjs +6 -9
  46. package/dist/params.cjs.map +1 -1
  47. package/dist/params.js +6 -6
  48. package/dist/params.js.map +1 -1
  49. package/dist/s3.cjs +286 -0
  50. package/dist/s3.cjs.map +1 -0
  51. package/dist/s3.js +273 -0
  52. package/dist/s3.js.map +1 -0
  53. package/dist/screen.cjs +34 -39
  54. package/dist/screen.cjs.map +1 -1
  55. package/dist/screen.js +48 -46
  56. package/dist/screen.js.map +1 -1
  57. package/dist/tasks.cjs +1354 -501
  58. package/dist/tasks.cjs.map +1 -1
  59. package/dist/tasks.js +1369 -527
  60. package/dist/tasks.js.map +1 -1
  61. package/dist/utils.cjs +7 -8
  62. package/dist/utils.cjs.map +1 -1
  63. package/dist/utils.js +6 -6
  64. package/dist/utils.js.map +1 -1
  65. package/package.json +32 -47
  66. package/scripts/ssm/{parse-cli.ts → parse-cli.js} +4 -4
  67. package/scripts/ssm/{ssm-admin.ts → ssm-admin.js} +12 -12
  68. package/scripts/ssm/{ssm-pull.ts → ssm-pull.js} +10 -13
@@ -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
  }
@@ -2114,11 +2109,11 @@ var Params = class _Params {
2114
2109
  }
2115
2110
  };
2116
2111
 
2117
- // src/logger/index.ts
2112
+ // src/logger/index.js
2118
2113
  import chalk from "chalk";
2119
2114
  import util from "util";
2120
2115
 
2121
- // src/logger/transports.ts
2116
+ // src/logger/transports.js
2122
2117
  var ConsoleTransport = class {
2123
2118
  write(payload) {
2124
2119
  console.info(payload);
@@ -2138,7 +2133,7 @@ var ParentProcessTransport = class {
2138
2133
  }
2139
2134
  };
2140
2135
 
2141
- // src/logger/index.ts
2136
+ // src/logger/index.js
2142
2137
  var ALL_LEVELS = [
2143
2138
  "silly",
2144
2139
  "debug",
@@ -2211,6 +2206,7 @@ var Logger = class _Logger {
2211
2206
  }
2212
2207
  /**
2213
2208
  * Initialize logger from context and CLI parameters. Whatever is in options goes (after discovered params).
2209
+ * Params are tracked under the `logger` module for --showUsedParams.
2214
2210
  */
2215
2211
  static init(context, options) {
2216
2212
  const paramDefs = {
@@ -2224,7 +2220,7 @@ var Logger = class _Logger {
2224
2220
  progressWithTimes: "boolean default false",
2225
2221
  progressThrottleMs: "number"
2226
2222
  };
2227
- const discovered = context.params.getAllForModule(paramDefs);
2223
+ const discovered = context.params.getAllForModule("logger", paramDefs);
2228
2224
  const config2 = { ...discovered, ...options };
2229
2225
  const logger = new _Logger(context, config2);
2230
2226
  context.logger = logger;
@@ -2420,9 +2416,9 @@ var Logger = class _Logger {
2420
2416
  }
2421
2417
  };
2422
2418
 
2423
- // src/init/index.ts
2419
+ // src/init/index.js
2424
2420
  import { EventEmitter } from "events";
2425
- function extractComponentOptions(opts, componentName) {
2421
+ function extractComponentOptions(opts, _componentName) {
2426
2422
  const reservedKeys = ["overrides", "defaults", "modules"];
2427
2423
  const componentOptions = {};
2428
2424
  for (const [key, value] of Object.entries(opts)) {
@@ -2489,6 +2485,19 @@ function printAllParameters(context) {
2489
2485
  async function init(flow2, opts = {}) {
2490
2486
  let stop = false;
2491
2487
  let context = null;
2488
+ let cleanupRan = false;
2489
+ const runRegisteredCleanups = async (ctx) => {
2490
+ if (cleanupRan) return;
2491
+ cleanupRan = true;
2492
+ const fns = [...ctx.cleanupFunctions].reverse();
2493
+ for (const fn of fns) {
2494
+ try {
2495
+ await fn(ctx);
2496
+ } catch (error) {
2497
+ ctx.logger.warn("[cleanup] error in cleanup function:", error);
2498
+ }
2499
+ }
2500
+ };
2492
2501
  try {
2493
2502
  try {
2494
2503
  const screenModule = await Promise.resolve().then(() => (init_screen(), screen_exports));
@@ -2511,13 +2520,24 @@ async function init(flow2, opts = {}) {
2511
2520
  printAllParameters(context);
2512
2521
  process.exit(0);
2513
2522
  }
2523
+ let sigintCount = 0;
2514
2524
  process.on("SIGINT", async () => {
2515
- if (stop) {
2516
- context.logger.warn("[process] killed");
2517
- process.exit(2);
2525
+ if (!context) return;
2526
+ sigintCount += 1;
2527
+ if (sigintCount === 1) {
2528
+ stop = true;
2529
+ context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2530
+ context.emitter.emit("stop", stopAllowance);
2531
+ return;
2518
2532
  }
2533
+ context.logger.warn("[process] second SIGINT: running cleanup then exit");
2534
+ await runRegisteredCleanups(context);
2535
+ process.exit(2);
2536
+ });
2537
+ process.on("SIGTERM", () => {
2538
+ if (!context || stop) return;
2519
2539
  stop = true;
2520
- context.logger.info(`>> emitting stop with allowance ${stopAllowance}`);
2540
+ context.logger.info(`>> SIGTERM: emitting stop with allowance ${stopAllowance}`);
2521
2541
  context.emitter.emit("stop", stopAllowance);
2522
2542
  });
2523
2543
  await flow2(context);
@@ -2542,33 +2562,62 @@ async function init(flow2, opts = {}) {
2542
2562
  }
2543
2563
  } finally {
2544
2564
  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
- }
2565
+ await runRegisteredCleanups(context);
2552
2566
  }
2553
2567
  }
2554
2568
  }
2555
2569
 
2556
- // src/db/index.ts
2570
+ // src/db/index.js
2557
2571
  import knex from "knex";
2572
+ var KNEX_DEFAULTS = {
2573
+ testConnection: true,
2574
+ pool: { min: 2, max: 10 },
2575
+ acquireConnectionTimeout: 1e4,
2576
+ ssl: { rejectUnauthorized: false }
2577
+ };
2558
2578
  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
- */
2579
+ static async init(context, options = {}) {
2580
+ const defs2 = {
2581
+ dbName: "string",
2582
+ dbConnectionString: "string",
2583
+ dbProfile: "boolean default false"
2584
+ };
2585
+ const discovered = context?.params?.getAllForModule?.("db", defs2) ?? {};
2586
+ const merged = { ...discovered, ...options };
2587
+ let { dbName, dbConnectionString } = merged;
2588
+ const { dbProfile } = merged;
2589
+ if (!dbName && !dbConnectionString) {
2590
+ dbName = "local";
2591
+ }
2592
+ if (dbName && /^(postgresql|mysql):\/\//.test(dbName)) {
2593
+ dbConnectionString = dbName;
2594
+ dbName = void 0;
2595
+ }
2596
+ if (dbName && !dbConnectionString) {
2597
+ const paramName = `dbConnectionString${capitalizeFirstLetter(dbName)}`;
2598
+ dbConnectionString = await context.params.get(paramName, "string");
2599
+ if (!dbConnectionString) {
2600
+ throw new ParamError(
2601
+ `Db: cannot find dbConnectionString for dbName="${dbName}" (looked for param "${paramName}")`
2602
+ );
2603
+ }
2604
+ }
2605
+ const config2 = {
2606
+ ...KNEX_DEFAULTS,
2607
+ connectionString: dbConnectionString,
2608
+ name: dbName || merged.name || "default",
2609
+ profile: !!dbProfile,
2610
+ logger: context.logger
2611
+ };
2612
+ return dbConnect(context, config2);
2613
+ }
2568
2614
  constructor(config2) {
2569
- if (!config2.connectionString) {
2615
+ if (!config2 || !config2.connectionString) {
2570
2616
  throw new ParamError("Db: connectionString is required");
2571
2617
  }
2618
+ this.knexInstance = null;
2619
+ this.isConnected = false;
2620
+ this.queriesLog = [];
2572
2621
  this.config = {
2573
2622
  testConnection: true,
2574
2623
  profile: false,
@@ -2581,25 +2630,23 @@ var Db = class {
2581
2630
  };
2582
2631
  this.logger = this.config.logger;
2583
2632
  const instance = this;
2584
- const callableWrapper = function(...args) {
2633
+ const callableWrapper = function() {
2585
2634
  throw new Error("This should never be called directly");
2586
2635
  };
2587
2636
  callableWrapper._instance = instance;
2588
2637
  return new Proxy(callableWrapper, {
2589
- // Intercept function calls: db('table')
2590
- apply: (target, thisArg, argumentsList) => {
2638
+ apply: (target, _thisArg, argumentsList) => {
2591
2639
  const inst = target._instance;
2592
2640
  if (!inst.knexInstance) {
2593
2641
  throw new Error("Db: Not connected. Call connect() first.");
2594
2642
  }
2595
2643
  return inst.knexInstance(...argumentsList);
2596
2644
  },
2597
- // Intercept property access: db.schema, db.raw, etc.
2598
2645
  get: (target, prop) => {
2599
2646
  if (prop === "_instance") {
2600
2647
  return target._instance;
2601
2648
  }
2602
- const instance2 = target._instance;
2649
+ const inst = target._instance;
2603
2650
  const ownMethods = [
2604
2651
  "connect",
2605
2652
  "disconnect",
@@ -2612,26 +2659,26 @@ var Db = class {
2612
2659
  "detectClient",
2613
2660
  "attachProfiler"
2614
2661
  ];
2615
- if (prop in instance2) {
2616
- const value = instance2[prop];
2662
+ if (prop in inst) {
2663
+ const value = inst[prop];
2617
2664
  if (typeof value === "function" && ownMethods.includes(prop)) {
2618
- return value.bind(instance2);
2665
+ return value.bind(inst);
2619
2666
  }
2620
2667
  if (typeof value !== "function") {
2621
2668
  return value;
2622
2669
  }
2623
2670
  }
2624
- if (instance2.knexInstance) {
2625
- const knexProp = instance2.knexInstance[prop];
2671
+ if (inst.knexInstance) {
2672
+ const knexProp = inst.knexInstance[prop];
2626
2673
  if (typeof knexProp === "function") {
2627
- return knexProp.bind(instance2.knexInstance);
2674
+ return knexProp.bind(inst.knexInstance);
2628
2675
  }
2629
2676
  return knexProp;
2630
2677
  }
2631
- if (prop in instance2) {
2632
- const method = instance2[prop];
2678
+ if (prop in inst) {
2679
+ const method = inst[prop];
2633
2680
  if (typeof method === "function") {
2634
- return method.bind(instance2);
2681
+ return method.bind(inst);
2635
2682
  }
2636
2683
  return method;
2637
2684
  }
@@ -2639,9 +2686,6 @@ var Db = class {
2639
2686
  }
2640
2687
  });
2641
2688
  }
2642
- /**
2643
- * Detect database client type from connection string
2644
- */
2645
2689
  detectClient(connectionString) {
2646
2690
  if (connectionString.match(/^postgresql/)) {
2647
2691
  return "pg";
@@ -2651,9 +2695,6 @@ var Db = class {
2651
2695
  }
2652
2696
  return null;
2653
2697
  }
2654
- /**
2655
- * Connect to the database
2656
- */
2657
2698
  async connect() {
2658
2699
  if (this.isConnected && this.knexInstance) {
2659
2700
  this.logger.warn?.("[Db] Already connected");
@@ -2662,14 +2703,13 @@ var Db = class {
2662
2703
  const client = this.detectClient(this.config.connectionString);
2663
2704
  if (!client) {
2664
2705
  throw new ParamError(
2665
- `Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://`
2706
+ "Db: Cannot determine client type from connection string. Expected postgresql:// or mysql://"
2666
2707
  );
2667
2708
  }
2668
2709
  try {
2669
2710
  const connectionConfig = {
2670
2711
  connectionString: this.config.connectionString,
2671
2712
  family: 4
2672
- // Force IPv4 only (disable IPv6)
2673
2713
  };
2674
2714
  this.knexInstance = knex({
2675
2715
  client,
@@ -2685,7 +2725,9 @@ var Db = class {
2685
2725
  await this.testConnection();
2686
2726
  }
2687
2727
  this.isConnected = true;
2688
- this.logger.debug?.(`[Db] Connected to database "${this.config.name || this.config.connectionString}"`);
2728
+ this.logger.debug?.(
2729
+ `[Db] Connected to database "${this.config.name || this.config.connectionString}"`
2730
+ );
2689
2731
  } catch (error) {
2690
2732
  if (error instanceof ParamError) {
2691
2733
  throw error;
@@ -2694,9 +2736,6 @@ var Db = class {
2694
2736
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
2695
2737
  }
2696
2738
  }
2697
- /**
2698
- * Disconnect from the database
2699
- */
2700
2739
  async disconnect() {
2701
2740
  if (!this.knexInstance) {
2702
2741
  return;
@@ -2706,16 +2745,15 @@ var Db = class {
2706
2745
  this.knexInstance = null;
2707
2746
  this.isConnected = false;
2708
2747
  this.queriesLog = [];
2709
- this.logger.debug?.(`[Db] Disconnected from database "${this.config.name || this.config.connectionString}"`);
2748
+ this.logger.debug?.(
2749
+ `[Db] Disconnected from database "${this.config.name || this.config.connectionString}"`
2750
+ );
2710
2751
  } catch (error) {
2711
2752
  const errorMsg = this.getErrorMessage(error);
2712
2753
  this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
2713
2754
  throw error;
2714
2755
  }
2715
2756
  }
2716
- /**
2717
- * Extract error message from various error types
2718
- */
2719
2757
  getErrorMessage(error) {
2720
2758
  if (error instanceof AggregateError) {
2721
2759
  const errors = error.errors || [];
@@ -2740,9 +2778,11 @@ var Db = class {
2740
2778
  return `${code} (tried: ${addresses.join(", ")})`;
2741
2779
  }
2742
2780
  }
2743
- const uniqueMessages = [...new Set(errors.map((e) => {
2744
- return e instanceof Error ? e.message : String(e);
2745
- }))];
2781
+ const uniqueMessages = [
2782
+ ...new Set(
2783
+ errors.map((e) => e instanceof Error ? e.message : String(e))
2784
+ )
2785
+ ];
2746
2786
  if (uniqueMessages.length === 1) {
2747
2787
  return uniqueMessages[0];
2748
2788
  }
@@ -2751,28 +2791,25 @@ var Db = class {
2751
2791
  return error.message || "Multiple errors occurred";
2752
2792
  }
2753
2793
  if (error instanceof Error) {
2754
- const errorWithCode = error;
2755
- if (errorWithCode.code) {
2756
- return `${errorWithCode.code}: ${error.message || String(error)}`;
2794
+ const code = error.code;
2795
+ if (code) {
2796
+ return `${code}: ${error.message || String(error)}`;
2757
2797
  }
2758
2798
  return error.message || String(error);
2759
2799
  }
2760
2800
  if (typeof error === "string") {
2761
2801
  return error;
2762
2802
  }
2763
- if (error?.message) {
2803
+ if (error && typeof error === "object" && "message" in error) {
2764
2804
  const msg = String(error.message);
2765
- const errorWithCode = error;
2766
- if (errorWithCode.code) {
2767
- return `${errorWithCode.code}: ${msg}`;
2805
+ const code = error.code;
2806
+ if (code) {
2807
+ return `${code}: ${msg}`;
2768
2808
  }
2769
2809
  return msg;
2770
2810
  }
2771
2811
  return String(error) || "Unknown error";
2772
2812
  }
2773
- /**
2774
- * Test database connection
2775
- */
2776
2813
  async testConnection() {
2777
2814
  if (!this.knexInstance) {
2778
2815
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2788,9 +2825,6 @@ var Db = class {
2788
2825
  throw new ParamError(`Db: Connection test failed - ${errorMsg}`);
2789
2826
  }
2790
2827
  }
2791
- /**
2792
- * Attach query profiler to log all queries
2793
- */
2794
2828
  attachProfiler() {
2795
2829
  if (!this.knexInstance) {
2796
2830
  return;
@@ -2800,7 +2834,7 @@ var Db = class {
2800
2834
  this.knexInstance.on("query", (query) => {
2801
2835
  query.__startTime = process.hrtime();
2802
2836
  });
2803
- this.knexInstance.on("query-response", (response, query) => {
2837
+ this.knexInstance.on("query-response", (_response, query) => {
2804
2838
  const [seconds, nanoseconds] = process.hrtime(query.__startTime);
2805
2839
  const executionTimeMs = (seconds * 1e3 + nanoseconds / 1e6).toFixed(2);
2806
2840
  const logEntry = {
@@ -2815,15 +2849,9 @@ var Db = class {
2815
2849
  this.logger.error?.(`[Db] Query failed: ${query.sql}`, error);
2816
2850
  });
2817
2851
  }
2818
- /**
2819
- * Get query log (only available if profiling is enabled)
2820
- */
2821
2852
  getQueryLog() {
2822
2853
  return [...this.queriesLog];
2823
2854
  }
2824
- /**
2825
- * Check if a table exists
2826
- */
2827
2855
  async tableExists(tableName) {
2828
2856
  if (!this.knexInstance) {
2829
2857
  throw new Error("Db: Not connected. Call connect() first.");
@@ -2835,65 +2863,28 @@ var Db = class {
2835
2863
  throw error;
2836
2864
  }
2837
2865
  }
2838
- /**
2839
- * Get the underlying Knex instance (for advanced usage)
2840
- */
2841
2866
  getKnex() {
2842
2867
  if (!this.knexInstance) {
2843
2868
  throw new Error("Db: Not connected. Call connect() first.");
2844
2869
  }
2845
2870
  return this.knexInstance;
2846
2871
  }
2847
- /**
2848
- * Get connection status
2849
- */
2850
2872
  isConnectedToDb() {
2851
2873
  return this.isConnected && this.knexInstance !== null;
2852
2874
  }
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
2875
  };
2861
2876
  function capitalizeFirstLetter(str) {
2862
2877
  return str.charAt(0).toUpperCase() + str.slice(1);
2863
2878
  }
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
- };
2879
+ async function dbConnect(context, config2) {
2889
2880
  try {
2890
2881
  const db = new Db(config2);
2891
2882
  context.registerCleanup(async () => {
2892
2883
  await db.disconnect();
2893
- context.logger.debug(`[Db] instance "${name || connectionString}" destroyed`);
2884
+ context.logger.debug?.(`[Db] instance "${config2.name}" disconnected`);
2894
2885
  });
2895
2886
  await db.connect();
2896
- context.logger.debug(`[Db] instance "${name || connectionString}" initialized`);
2887
+ context.logger.debug?.(`[Db] instance "${config2.name}" initialized`);
2897
2888
  return db;
2898
2889
  } catch (error) {
2899
2890
  if (error instanceof ParamError) {
@@ -2903,48 +2894,11 @@ async function dbConnect(context, connectionString, name, dbProfile) {
2903
2894
  throw new ParamError(`[Db] connect error: ${errorMsg}`);
2904
2895
  }
2905
2896
  }
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
2897
 
2947
- // src/utils/date-utils.ts
2898
+ // src/tasks/index.js
2899
+ import os3 from "os";
2900
+
2901
+ // src/utils/date-utils.js
2948
2902
  function isTimestampFolder(folderName) {
2949
2903
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|\.\d{3}Z)$/;
2950
2904
  if (!isoRegex.test(folderName)) {
@@ -2954,7 +2908,7 @@ function isTimestampFolder(folderName) {
2954
2908
  return !isNaN(date.getTime()) && date.getTime() > 0;
2955
2909
  }
2956
2910
 
2957
- // src/utils/fs-utils.ts
2911
+ // src/utils/fs-utils.js
2958
2912
  import fs from "fs";
2959
2913
  import path from "path";
2960
2914
  async function ensurePath(...pathParts) {
@@ -2978,7 +2932,7 @@ function getFileExtension(dataType) {
2978
2932
  }
2979
2933
  }
2980
2934
 
2981
- // src/utils/os-utils.ts
2935
+ // src/utils/os-utils.js
2982
2936
  import fs2 from "fs";
2983
2937
  import path2 from "path";
2984
2938
  import { execSync } from "child_process";
@@ -3007,7 +2961,7 @@ function getFreeDiskSpace(targetPath) {
3007
2961
  }
3008
2962
  }
3009
2963
 
3010
- // src/utils/format-utils.ts
2964
+ // src/utils/format-utils.js
3011
2965
  function bytesToHumanReadable(bytes) {
3012
2966
  if (bytes === 0) return "0 B";
3013
2967
  const k = 1024;
@@ -3016,7 +2970,7 @@ function bytesToHumanReadable(bytes) {
3016
2970
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
3017
2971
  }
3018
2972
 
3019
- // src/utils/core-utils.ts
2973
+ // src/utils/core-utils.js
3020
2974
  function sleepMs(ms) {
3021
2975
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3022
2976
  }
@@ -3025,14 +2979,82 @@ function toJsonColumn(value) {
3025
2979
  return JSON.stringify(value);
3026
2980
  }
3027
2981
 
3028
- // src/tasks/servicesRegistry.ts
3029
- import { randomUUID as randomUUID2 } from "crypto";
3030
- import { mkdir, readFile, writeFile } from "fs/promises";
2982
+ // src/tasks/servicesRegistry.js
3031
2983
  import os from "os";
3032
- import path3 from "path";
3033
2984
 
3034
- // src/tasks/taskUtils.ts
2985
+ // src/tasks/taskUtils.js
3035
2986
  import { randomUUID } from "crypto";
2987
+
2988
+ // src/tasks/time-matcher.js
2989
+ var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
2990
+ function resolveAsterisks(field, range) {
2991
+ return field.includes("*") ? field.replace("*", range) : field;
2992
+ }
2993
+ function resolveRanges(field) {
2994
+ const regex = /(\d+)-(\d+)/;
2995
+ let current = field;
2996
+ while (true) {
2997
+ const match = regex.exec(current);
2998
+ if (!match) break;
2999
+ const raw = match[0];
3000
+ let first = Number(match[1]);
3001
+ let last = Number(match[2]);
3002
+ if (last < first) {
3003
+ [first, last] = [last, first];
3004
+ }
3005
+ const values = [];
3006
+ for (let i = first; i <= last; i += 1) {
3007
+ values.push(i);
3008
+ }
3009
+ current = current.replace(raw, values.join(","));
3010
+ }
3011
+ return current;
3012
+ }
3013
+ function resolveSteps(field) {
3014
+ const match = /^(.+)\/(\d+)$/.exec(field);
3015
+ if (!match) return field;
3016
+ const base = match[1];
3017
+ const step = Number(match[2]);
3018
+ if (!Number.isFinite(step) || step <= 0) return field;
3019
+ return base.split(",").map((v) => Number(v)).filter((v) => Number.isFinite(v) && v % step === 0).join(",");
3020
+ }
3021
+ function convertPattern(pattern) {
3022
+ const parts = pattern.trim().split(/\s+/);
3023
+ if (parts.length !== 6) {
3024
+ throw new Error(`Invalid schedule "${pattern}". Expected 6 fields: sec min hour day month weekday`);
3025
+ }
3026
+ return parts.map((field, idx) => resolveAsterisks(field, RANGES[idx])).map((field) => resolveRanges(field)).map((field) => resolveSteps(field));
3027
+ }
3028
+ function fieldMatches(field, value) {
3029
+ const allowed = field.split(",").map((v) => Number(v));
3030
+ return allowed.includes(value);
3031
+ }
3032
+ function matchesParsedPattern(parsed, date) {
3033
+ 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());
3034
+ }
3035
+ function timeMatcher(pattern, date = /* @__PURE__ */ new Date()) {
3036
+ const parsed = convertPattern(pattern);
3037
+ return matchesParsedPattern(parsed, date);
3038
+ }
3039
+ var MS_PER_SECOND = 1e3;
3040
+ var DEFAULT_SEARCH_HORIZON_MS = 10 * 365 * 24 * 60 * 60 * MS_PER_SECOND;
3041
+ function nextTimeMatch(pattern, from = /* @__PURE__ */ new Date(), maxSearchMs = DEFAULT_SEARCH_HORIZON_MS) {
3042
+ const parsed = convertPattern(pattern);
3043
+ let t = Math.ceil((from.getTime() + 1) / MS_PER_SECOND) * MS_PER_SECOND;
3044
+ const end = t + maxSearchMs;
3045
+ while (t <= end) {
3046
+ const date = new Date(t);
3047
+ if (matchesParsedPattern(parsed, date)) {
3048
+ return date;
3049
+ }
3050
+ t += MS_PER_SECOND;
3051
+ }
3052
+ throw new Error(
3053
+ `nextTimeMatch: no match for "${pattern}" within ${maxSearchMs}ms after ${from.toISOString()}`
3054
+ );
3055
+ }
3056
+
3057
+ // src/tasks/taskUtils.js
3036
3058
  function getDb(context) {
3037
3059
  const db = context.db;
3038
3060
  if (!db) {
@@ -3040,97 +3062,84 @@ function getDb(context) {
3040
3062
  }
3041
3063
  return db;
3042
3064
  }
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
- }
3065
+ function queueToTableNames(queueName) {
3047
3066
  return {
3048
- tasksTable: queue,
3049
- historyTable: `${queue}_history`
3067
+ tasksTable: queueName,
3068
+ historyTable: `${queueName}_history`,
3069
+ registryTable: `${queueName}_services_registry`
3050
3070
  };
3051
3071
  }
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`;
3072
+ function defineTasksTable(t, db, tableNameForIndex) {
3073
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3074
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3075
+ t.timestamp("started_at");
3076
+ t.timestamp("completed_at");
3077
+ t.integer("priority").notNullable().defaultTo(50);
3078
+ t.text("schedule");
3079
+ t.timestamp("next_run_at").defaultTo(null);
3080
+ t.timestamp("past_due").defaultTo(null);
3081
+ t.text("name").notNullable();
3082
+ t.text("opid");
3083
+ t.json("params");
3084
+ t.text("service_group");
3085
+ t.integer("instance_number");
3086
+ t.text("service_name");
3087
+ t.text("server_name");
3088
+ t.text("status").notNullable().defaultTo("idle");
3089
+ t.timestamp("status_changed_at").defaultTo(null);
3090
+ t.text("progress");
3091
+ t.boolean("success");
3092
+ t.json("results");
3093
+ t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
3094
+ t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
3095
+ }
3096
+ function taskHistoryInsertFromQueueRow(row, overrides) {
3097
+ const { id, ...snapshot } = row;
3098
+ void id;
3099
+ return {
3100
+ ...snapshot,
3101
+ ...overrides
3102
+ };
3057
3103
  }
3058
3104
  async function ensureTaskTables(context, options = {}) {
3059
- const queue = options.queue ?? "tasks";
3105
+ const queueName = options.queueName ?? "tasks";
3060
3106
  const recreate = options.recreate ?? false;
3061
3107
  const db = getDb(context);
3062
- const { tasksTable, historyTable } = queueToTableNames(queue);
3108
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
3063
3109
  const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
3064
3110
  const needsHistory = recreate ? true : !await db.tableExists(historyTable);
3111
+ const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
3065
3112
  if (recreate) {
3066
3113
  await db.schema.dropTableIfExists(historyTable);
3067
3114
  await db.schema.dropTableIfExists(tasksTable);
3115
+ await db.schema.dropTableIfExists(registryTable);
3068
3116
  }
3069
3117
  if (needsTasks) {
3070
3118
  await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
3071
3119
  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`);
3120
+ defineTasksTable(t, db, tasksTable);
3092
3121
  });
3093
3122
  }
3094
3123
  if (needsHistory) {
3095
3124
  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`);
3125
+ defineTasksTable(t, db, historyTable);
3114
3126
  });
3115
3127
  }
3116
- const registryTable = servicesRegistryTable(queue);
3117
- const needsRegistry = !await db.tableExists(registryTable);
3118
3128
  if (needsRegistry) {
3119
3129
  await db.schema.createTable(registryTable, (t) => {
3120
3130
  t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
3121
- t.uuid("instance_id").notNullable().unique();
3122
- t.text("queue").notNullable();
3131
+ t.text("queue_name").notNullable();
3123
3132
  t.text("service_group").notNullable();
3133
+ t.integer("instance_number").notNullable().defaultTo(1);
3124
3134
  t.text("service_name").notNullable();
3125
- t.text("target").notNullable();
3126
- t.text("hostname");
3135
+ t.text("server_name").notNullable();
3127
3136
  t.integer("pid");
3128
3137
  t.json("metadata");
3129
3138
  t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
3130
3139
  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`);
3140
+ t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
3141
+ t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
3142
+ t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
3134
3143
  });
3135
3144
  }
3136
3145
  }
@@ -3141,7 +3150,7 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
3141
3150
  });
3142
3151
  }
3143
3152
 
3144
- // src/tasks/servicesRegistry.ts
3153
+ // src/tasks/servicesRegistry.js
3145
3154
  function getDb2(context) {
3146
3155
  const db = context.db;
3147
3156
  if (!db) {
@@ -3152,6 +3161,7 @@ function getDb2(context) {
3152
3161
  var DEFAULT_GROUP_MAX_INSTANCES = {
3153
3162
  intake: 1,
3154
3163
  harvest: 1,
3164
+ harvester: 0,
3155
3165
  loader: 0,
3156
3166
  photos: 0,
3157
3167
  photosprocessor: 0,
@@ -3161,25 +3171,6 @@ function sanitizeNamePart(raw) {
3161
3171
  const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
3162
3172
  return s.slice(0, 80) || "runner";
3163
3173
  }
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
3174
  function resolveMaxInstances(serviceGroup, override) {
3184
3175
  if (override !== void 0 && Number.isFinite(override)) {
3185
3176
  return Math.max(0, Math.floor(Number(override)));
@@ -3187,184 +3178,198 @@ function resolveMaxInstances(serviceGroup, override) {
3187
3178
  const g = serviceGroup.trim().toLowerCase();
3188
3179
  return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
3189
3180
  }
3190
- async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
3181
+ async function countAliveInGroup(db, registryTable, queueName, serviceGroup, staleMs, excludeRowId) {
3191
3182
  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);
3183
+ let q = db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
3184
+ if (excludeRowId) {
3185
+ q = q.whereNot("id", excludeRowId);
3195
3186
  }
3196
3187
  const row = await q.count("id as count").first();
3197
3188
  return Number(row?.count ?? 0);
3198
3189
  }
3190
+ async function getOccupiedInstanceSlots(db, registryTable, queueName, serviceGroup, staleMs) {
3191
+ const cutoff = new Date(Date.now() - staleMs);
3192
+ const rows = await db(registryTable).where({ queue_name: queueName, service_group: serviceGroup }).where("last_seen_at", ">", cutoff).select("instance_number");
3193
+ const set = /* @__PURE__ */ new Set();
3194
+ for (const r of rows) {
3195
+ const n = Number(r.instance_number);
3196
+ if (Number.isFinite(n) && n >= 1) set.add(Math.floor(n));
3197
+ }
3198
+ return set;
3199
+ }
3199
3200
  function isUniqueViolation(error) {
3200
3201
  const code = error?.code ?? error?.errno;
3201
3202
  return code === "23505" || String(error?.message || "").includes("duplicate key");
3202
3203
  }
3204
+ function buildMetadata(options) {
3205
+ const base = options.metadata && typeof options.metadata === "object" ? { ...options.metadata } : {};
3206
+ if (options.target) {
3207
+ base.runnerTarget = options.target;
3208
+ }
3209
+ return toJsonColumn(Object.keys(base).length ? base : null);
3210
+ }
3211
+ function allocateInstanceNumber(occupied, explicit, maxSlots) {
3212
+ if (explicit !== void 0 && explicit !== null && Number.isFinite(Number(explicit))) {
3213
+ const e = Math.max(1, Math.floor(Number(explicit)));
3214
+ if (occupied.has(e)) {
3215
+ throw new Error(`[services-registry] instance slot ${e} is already occupied by an alive peer`);
3216
+ }
3217
+ if (maxSlots !== void 0 && maxSlots > 0 && e > maxSlots) {
3218
+ throw new Error(`[services-registry] instance ${e} exceeds configured max slots ${maxSlots} for this group`);
3219
+ }
3220
+ return e;
3221
+ }
3222
+ const cap = maxSlots !== void 0 && maxSlots > 0 ? maxSlots : 1e4;
3223
+ for (let n = 1; n <= cap; n++) {
3224
+ if (!occupied.has(n)) return n;
3225
+ }
3226
+ throw new Error(`[services-registry] no free instance slot (searched 1..${cap})`);
3227
+ }
3228
+ function defaultServiceName(groupBase, hostBase, instanceNumber) {
3229
+ return `${groupBase}-${hostBase}-${instanceNumber}`;
3230
+ }
3203
3231
  async function registerInServicesRegistry(context, options) {
3204
3232
  const db = getDb2(context);
3205
- const registryTable = servicesRegistryTable(options.queue);
3233
+ const registryTable = queueToTableNames(options.queueName).registryTable;
3206
3234
  const serviceGroup = options.serviceGroup.trim();
3207
3235
  if (!serviceGroup) {
3208
3236
  throw new Error("registerInServicesRegistry: serviceGroup is required");
3209
3237
  }
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();
3238
+ const serverName = os.hostname();
3216
3239
  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
- }
3240
+ const meta = buildMetadata(options);
3241
+ const groupBase = sanitizeNamePart(serviceGroup);
3242
+ const hostBase = sanitizeNamePart(serverName);
3253
3243
  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}).`;
3244
+ const aliveCount = await countAliveInGroup(db, registryTable, options.queueName, serviceGroup, options.staleMs, void 0);
3245
+ if (maxAllowed > 0 && aliveCount >= maxAllowed) {
3246
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveCount} alive (max ${maxAllowed}, queue=${options.queueName}).`;
3264
3247
  if (options.enforceMaxInstances) {
3265
3248
  throw new Error(msg);
3266
3249
  }
3267
3250
  context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
3268
3251
  }
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;
3252
+ const maxSlots = maxAllowed > 0 ? maxAllowed : void 0;
3253
+ const cutoff = new Date(Date.now() - options.staleMs);
3254
+ const MAX_ATTEMPTS = 8;
3255
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
3256
+ const occupied = await getOccupiedInstanceSlots(db, registryTable, options.queueName, serviceGroup, options.staleMs);
3257
+ const instanceNumber = allocateInstanceNumber(occupied, options.instanceNumber, maxSlots);
3258
+ const serviceNameRaw = options.serviceName?.trim() ? sanitizeNamePart(options.serviceName.trim()) : defaultServiceName(groupBase, hostBase, instanceNumber);
3259
+ const existing = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3260
+ if (existing) {
3261
+ const lastSeen = new Date(existing.last_seen_at);
3262
+ const isAlive = !Number.isNaN(lastSeen.getTime()) && lastSeen > cutoff;
3263
+ if (isAlive) {
3264
+ if (options.serviceName?.trim()) {
3265
+ throw new Error(
3266
+ `[services-registry] service_name "${serviceNameRaw}" is already registered by an alive peer`
3267
+ );
3268
+ }
3269
+ context.logger.warn?.(
3270
+ `[services-registry] service_name "${serviceNameRaw}" already alive; retrying allocation (attempt ${attempt + 1})`
3271
+ );
3272
+ if (options.instanceNumber !== void 0 && options.instanceNumber !== null) {
3273
+ throw new Error(
3274
+ `[services-registry] instance slot ${instanceNumber} / name "${serviceNameRaw}" is already held by an alive peer`
3275
+ );
3276
+ }
3277
+ await new Promise((r) => setTimeout(r, 50 + attempt * 30));
3278
+ continue;
3289
3279
  }
3280
+ await db(registryTable).where({ id: existing.id }).update({
3281
+ server_name: serverName,
3282
+ pid,
3283
+ metadata: meta,
3284
+ service_group: serviceGroup,
3285
+ instance_number: instanceNumber,
3286
+ last_seen_at: db.fn.now()
3287
+ });
3288
+ const reg = {
3289
+ serviceName: serviceNameRaw,
3290
+ serviceGroup,
3291
+ queueName: options.queueName,
3292
+ target: options.target,
3293
+ rowId: String(existing.id),
3294
+ registryTable,
3295
+ instanceNumber
3296
+ };
3297
+ context.servicesRegistry = reg;
3298
+ context.runnerHeartbeat = reg;
3299
+ context.logger.info?.(
3300
+ `[services-registry] took over stale row name=${serviceNameRaw} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3301
+ );
3302
+ return reg;
3290
3303
  }
3291
- }
3292
- let inserted;
3293
- for (const candidate of eachServiceNameCandidate(baseCandidates)) {
3294
3304
  try {
3295
3305
  const rows = await db(registryTable).insert({
3296
- instance_id: instanceId,
3297
- queue: options.queue,
3306
+ queue_name: options.queueName,
3298
3307
  service_group: serviceGroup,
3299
- service_name: candidate,
3300
- target: options.target,
3301
- hostname,
3308
+ instance_number: instanceNumber,
3309
+ service_name: serviceNameRaw,
3310
+ server_name: serverName,
3302
3311
  pid,
3303
3312
  metadata: meta,
3304
- last_seen_at: db.fn.now()
3313
+ last_seen_at: db.fn.now(),
3314
+ created_at: db.fn.now()
3305
3315
  }).returning(["id", "service_name"]);
3306
3316
  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;
3317
+ let rowId = row && typeof row === "object" ? String(row.id ?? "") : "";
3318
+ if (!rowId) {
3319
+ const again = await db(registryTable).where({ queue_name: options.queueName, service_name: serviceNameRaw }).first();
3320
+ rowId = again?.id != null ? String(again.id) : "";
3310
3321
  }
3322
+ if (!rowId) continue;
3323
+ const regNew = {
3324
+ serviceName: String(row?.service_name ?? serviceNameRaw),
3325
+ serviceGroup,
3326
+ queueName: options.queueName,
3327
+ target: options.target,
3328
+ rowId,
3329
+ registryTable,
3330
+ instanceNumber
3331
+ };
3332
+ context.servicesRegistry = regNew;
3333
+ context.runnerHeartbeat = regNew;
3334
+ context.logger.info?.(
3335
+ `[services-registry] registered name=${regNew.serviceName} instance=${instanceNumber} group=${serviceGroup} queue=${options.queueName}`
3336
+ );
3337
+ return regNew;
3311
3338
  } catch (error) {
3312
3339
  if (!isUniqueViolation(error)) {
3313
3340
  throw error;
3314
3341
  }
3342
+ context.logger.warn?.(`[services-registry] insert race on "${serviceNameRaw}", retrying (attempt ${attempt + 1})`);
3315
3343
  }
3316
3344
  }
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}`
3345
+ throw new Error(
3346
+ `[services-registry] could not allocate a registry row for group=${serviceGroup} queue=${options.queueName} after ${MAX_ATTEMPTS} attempts`
3336
3347
  );
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
3348
  }
3347
3349
  async function touchServicesRegistry(context, registration) {
3348
3350
  const db = getDb2(context);
3349
- const hostname = os.hostname();
3351
+ const serverName = os.hostname();
3350
3352
  const pid = typeof process.pid === "number" ? process.pid : null;
3351
- await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
3353
+ await db(registration.registryTable).where({ id: registration.rowId }).update({
3352
3354
  last_seen_at: db.fn.now(),
3353
- hostname,
3355
+ server_name: serverName,
3354
3356
  pid
3355
3357
  });
3356
3358
  }
3357
3359
  async function unregisterServicesRegistry(context, registration) {
3358
3360
  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}`);
3361
+ await db(registration.registryTable).where({ id: registration.rowId }).delete();
3362
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} id=${registration.rowId}`);
3361
3363
  }
3362
3364
 
3363
- // src/filedatabase/index.ts
3364
- import fs3 from "fs";
3365
+ // src/tasks/taskLogs.js
3365
3366
  import path4 from "path";
3366
3367
 
3367
- // src/filedatabase/serializers.ts
3368
+ // src/filedatabase/index.js
3369
+ import fs3 from "fs";
3370
+ import path3 from "path";
3371
+
3372
+ // src/filedatabase/serializers.js
3368
3373
  function detectDataType(data) {
3369
3374
  if (Array.isArray(data)) {
3370
3375
  return "json-array";
@@ -3396,7 +3401,7 @@ function deserializeData(rawData, dataType) {
3396
3401
  }
3397
3402
  }
3398
3403
 
3399
- // src/filedatabase/index.ts
3404
+ // src/filedatabase/index.js
3400
3405
  var FileDatabase = class _FileDatabase {
3401
3406
  basePath;
3402
3407
  namespace;
@@ -3482,14 +3487,14 @@ var FileDatabase = class _FileDatabase {
3482
3487
  if (errors.length) {
3483
3488
  throw new FileDatabaseError(`[FileDatabase] ${errors.join("; ")}`);
3484
3489
  }
3485
- let parts = [this.basePath, this.namespace];
3490
+ const parts = [this.basePath, this.namespace];
3486
3491
  if (this.tableName) {
3487
3492
  parts.push(...this.tableName.split("/"));
3488
3493
  }
3489
3494
  if (this.versioned && version) {
3490
3495
  parts.push(version);
3491
3496
  }
3492
- return path4.resolve(...parts);
3497
+ return path3.resolve(...parts);
3493
3498
  }
3494
3499
  /**
3495
3500
  * Set current version and version folder
@@ -3526,7 +3531,7 @@ var FileDatabase = class _FileDatabase {
3526
3531
  this.currentFileNumber = 0;
3527
3532
  const versions = await this.getVersions();
3528
3533
  while (versions.length > this.maxVersions) {
3529
- const versionToDelete = path4.resolve(this.getDestinationPath(), versions.shift());
3534
+ const versionToDelete = path3.resolve(this.getDestinationPath(), versions.shift());
3530
3535
  this.logger.silly?.(`[FileDatabase] Deleting old version: ${versionToDelete}`);
3531
3536
  await fs3.promises.rm(versionToDelete, { recursive: true, force: true });
3532
3537
  }
@@ -3545,7 +3550,7 @@ var FileDatabase = class _FileDatabase {
3545
3550
  await ensurePath(destPath);
3546
3551
  const items = await fs3.promises.readdir(destPath);
3547
3552
  const versions = items.filter((item) => {
3548
- const itemPath = path4.join(destPath, item);
3553
+ const itemPath = path3.join(destPath, item);
3549
3554
  const stat = fs3.statSync(itemPath);
3550
3555
  return stat.isDirectory() && isTimestampFolder(item);
3551
3556
  });
@@ -3602,7 +3607,7 @@ var FileDatabase = class _FileDatabase {
3602
3607
  const items = await fs3.promises.readdir(tablePath);
3603
3608
  if (items.includes("metadata.json")) {
3604
3609
  const metadata = JSON.parse(
3605
- await fs3.promises.readFile(path4.join(tablePath, "metadata.json"), "utf8")
3610
+ await fs3.promises.readFile(path3.join(tablePath, "metadata.json"), "utf8")
3606
3611
  );
3607
3612
  return {
3608
3613
  versioned: false,
@@ -3611,13 +3616,13 @@ var FileDatabase = class _FileDatabase {
3611
3616
  };
3612
3617
  }
3613
3618
  const versionFolders = items.filter((item) => {
3614
- const itemPath = path4.join(tablePath, item);
3619
+ const itemPath = path3.join(tablePath, item);
3615
3620
  const stat = fs3.statSync(itemPath);
3616
3621
  return stat.isDirectory() && isTimestampFolder(item);
3617
3622
  });
3618
3623
  if (versionFolders.length > 0) {
3619
3624
  const latestVersion = versionFolders.sort().pop();
3620
- const versionMetadataPath = path4.join(tablePath, latestVersion, "metadata.json");
3625
+ const versionMetadataPath = path3.join(tablePath, latestVersion, "metadata.json");
3621
3626
  return {
3622
3627
  versioned: true,
3623
3628
  hasMetadata: fs3.existsSync(versionMetadataPath),
@@ -3638,7 +3643,7 @@ var FileDatabase = class _FileDatabase {
3638
3643
  * Load metadata from JSON file
3639
3644
  */
3640
3645
  async loadMetadataJson(version) {
3641
- const metadataFile = path4.join(this.getDestinationPath(), version, "metadata.json");
3646
+ const metadataFile = path3.join(this.getDestinationPath(), version, "metadata.json");
3642
3647
  if (fs3.existsSync(metadataFile)) {
3643
3648
  try {
3644
3649
  const rawData = await fs3.promises.readFile(metadataFile, "utf8");
@@ -3654,7 +3659,7 @@ var FileDatabase = class _FileDatabase {
3654
3659
  * Reads all files to get accurate counts - used when synopsis calculation is needed
3655
3660
  */
3656
3661
  async figureMetadataFromVersionFiles(version) {
3657
- const versionPath = path4.join(this.getDestinationPath(), version);
3662
+ const versionPath = path3.join(this.getDestinationPath(), version);
3658
3663
  if (!fs3.existsSync(versionPath)) {
3659
3664
  return this.getDefaultMetadata();
3660
3665
  }
@@ -3666,10 +3671,10 @@ var FileDatabase = class _FileDatabase {
3666
3671
  let detectedDataType = null;
3667
3672
  for (let i = 0; i < files.length; i++) {
3668
3673
  const fileName = files[i];
3669
- const filePath = path4.join(versionPath, fileName);
3674
+ const filePath = path3.join(versionPath, fileName);
3670
3675
  try {
3671
3676
  const rawData = await fs3.promises.readFile(filePath, "utf8");
3672
- const extension = path4.extname(fileName).toLowerCase();
3677
+ const extension = path3.extname(fileName).toLowerCase();
3673
3678
  let dataType = "text";
3674
3679
  if (extension === ".json") {
3675
3680
  dataType = "json-array";
@@ -3702,7 +3707,7 @@ var FileDatabase = class _FileDatabase {
3702
3707
  * Much faster for large datasets with many files
3703
3708
  */
3704
3709
  async buildMetadataOptimized(version) {
3705
- const versionPath = path4.join(this.getDestinationPath(), version);
3710
+ const versionPath = path3.join(this.getDestinationPath(), version);
3706
3711
  if (!fs3.existsSync(versionPath)) {
3707
3712
  return this.getDefaultMetadata();
3708
3713
  }
@@ -3718,7 +3723,7 @@ var FileDatabase = class _FileDatabase {
3718
3723
  fileName
3719
3724
  }));
3720
3725
  const firstFile = metadata.files[0];
3721
- const firstFilePath = path4.join(versionPath, firstFile.fileName);
3726
+ const firstFilePath = path3.join(versionPath, firstFile.fileName);
3722
3727
  const firstFileRaw = await fs3.promises.readFile(firstFilePath, "utf8");
3723
3728
  let firstFileData;
3724
3729
  try {
@@ -3735,7 +3740,7 @@ var FileDatabase = class _FileDatabase {
3735
3740
  }
3736
3741
  if (files.length > 1) {
3737
3742
  const lastFile = metadata.files[metadata.files.length - 1];
3738
- const lastFilePath = path4.join(versionPath, lastFile.fileName);
3743
+ const lastFilePath = path3.join(versionPath, lastFile.fileName);
3739
3744
  const lastFileRaw = await fs3.promises.readFile(lastFilePath, "utf8");
3740
3745
  const lastFileData = deserializeData(lastFileRaw, metadata.dataType);
3741
3746
  lastFile.recordsCount = Array.isArray(lastFileData) ? lastFileData.length : 1;
@@ -3786,9 +3791,9 @@ var FileDatabase = class _FileDatabase {
3786
3791
  if (!this.currentVersion) {
3787
3792
  return;
3788
3793
  }
3789
- metadataFile = path4.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
3794
+ metadataFile = path3.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
3790
3795
  } else {
3791
- metadataFile = path4.join(this.getDestinationPath(), "metadata.json");
3796
+ metadataFile = path3.join(this.getDestinationPath(), "metadata.json");
3792
3797
  }
3793
3798
  await fs3.promises.writeFile(metadataFile, JSON.stringify(metadataToSave, null, 4), "utf8");
3794
3799
  }
@@ -3851,7 +3856,7 @@ var FileDatabase = class _FileDatabase {
3851
3856
  }
3852
3857
  }
3853
3858
  if (!Array.isArray(data) && !forceNewFile) {
3854
- const lastFileExtension = path4.extname(lastFile.fileName);
3859
+ const lastFileExtension = path3.extname(lastFile.fileName);
3855
3860
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
3856
3861
  if (lastFileExtension !== expectedExtension) {
3857
3862
  if (lastFileRecordsCount > 0) {
@@ -3861,7 +3866,7 @@ var FileDatabase = class _FileDatabase {
3861
3866
  }
3862
3867
  }
3863
3868
  } else if (!Array.isArray(data) && forceNewFile) {
3864
- const lastFileExtension = path4.extname(lastFile.fileName);
3869
+ const lastFileExtension = path3.extname(lastFile.fileName);
3865
3870
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
3866
3871
  if (lastFileExtension !== expectedExtension) {
3867
3872
  lastFile.fileName = `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(incomingDataType)}`;
@@ -3951,7 +3956,7 @@ var FileDatabase = class _FileDatabase {
3951
3956
  */
3952
3957
  async safeWrite(filePath, data) {
3953
3958
  const serializedData = serializeData(data);
3954
- const dir = path4.dirname(filePath);
3959
+ const dir = path3.dirname(filePath);
3955
3960
  const requiredBytes = Buffer.byteLength(serializedData, "utf8");
3956
3961
  const freeBytes = getFreeDiskSpace(dir);
3957
3962
  if (freeBytes !== null) {
@@ -3975,14 +3980,17 @@ var FileDatabase = class _FileDatabase {
3975
3980
  * Prepare the instance for read or write operations
3976
3981
  * This discovers state and sets up internal members based on mode and current data
3977
3982
  */
3978
- async prepare({ write, read, version }) {
3983
+ async prepare(options) {
3984
+ const { write, read, version, deferInitialVersion } = options;
3979
3985
  if (write) {
3980
3986
  if (this.versioned) {
3981
3987
  if (this.currentVersion === null) {
3982
- await this.makeNewVersion();
3983
- this.metadata = this.getDefaultMetadata();
3984
- this.metadata.version = this.currentVersion;
3985
- this.makeNewFile();
3988
+ if (!deferInitialVersion) {
3989
+ await this.makeNewVersion();
3990
+ this.metadata = this.getDefaultMetadata();
3991
+ this.metadata.version = this.currentVersion;
3992
+ this.makeNewFile();
3993
+ }
3986
3994
  } else {
3987
3995
  if (!this.metadata.files.length) {
3988
3996
  this.metadata = await this.figureMetadata(this.currentVersion);
@@ -3996,7 +4004,7 @@ var FileDatabase = class _FileDatabase {
3996
4004
  } else {
3997
4005
  await ensurePath(this.getDestinationPath());
3998
4006
  if (this.useMetadata === true) {
3999
- const metadataPath = path4.join(this.getDestinationPath(), "metadata.json");
4007
+ const metadataPath = path3.join(this.getDestinationPath(), "metadata.json");
4000
4008
  if (fs3.existsSync(metadataPath)) {
4001
4009
  try {
4002
4010
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -4049,7 +4057,7 @@ var FileDatabase = class _FileDatabase {
4049
4057
  }
4050
4058
  if (this.useMetadata) {
4051
4059
  const destPath = this.getDestinationPath();
4052
- const metadataPath = path4.join(destPath, "metadata.json");
4060
+ const metadataPath = path3.join(destPath, "metadata.json");
4053
4061
  if (fs3.existsSync(metadataPath)) {
4054
4062
  try {
4055
4063
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -4085,14 +4093,14 @@ var FileDatabase = class _FileDatabase {
4085
4093
  if (options.filename) {
4086
4094
  const destPath2 = this.getDestinationPath();
4087
4095
  await ensurePath(destPath2);
4088
- const filePath = path4.join(destPath2, options.filename);
4096
+ const filePath = path3.join(destPath2, options.filename);
4089
4097
  await this.safeWrite(filePath, data);
4090
4098
  return;
4091
4099
  }
4092
4100
  if (options.forceNewVersion && !this.versioned) {
4093
4101
  throw new FileDatabaseError("Cannot use forceNewVersion in non-versioned mode");
4094
4102
  }
4095
- await this.prepare({ write: true });
4103
+ await this.prepare({ write: true, deferInitialVersion: !!(options.forceNewVersion && this.versioned) });
4096
4104
  const incomingDataType = detectDataType(data);
4097
4105
  this.metadata.dataType = incomingDataType;
4098
4106
  if (options.forceNewVersion) {
@@ -4134,11 +4142,11 @@ var FileDatabase = class _FileDatabase {
4134
4142
  const forceNewFile = hasCustomMetadata && targetFileIndex === null;
4135
4143
  let { dataToWrite, dataLeftOver, fileName } = this.figureOutDataAndFileToWrite(data, targetFileIndex, forceNewFile);
4136
4144
  const destPath = this.getDestinationPath(this.currentVersion || void 0);
4137
- await this.safeWrite(path4.join(destPath, fileName), dataToWrite);
4145
+ await this.safeWrite(path3.join(destPath, fileName), dataToWrite);
4138
4146
  this.updateMetadata(dataToWrite, fileName, options.customMetadata);
4139
4147
  while (dataLeftOver && dataLeftOver.length > 0 && targetFileIndex === null) {
4140
4148
  const writeContext = this.figureOutDataAndFileToWrite(dataLeftOver);
4141
- await this.safeWrite(path4.join(destPath, writeContext.fileName), writeContext.dataToWrite);
4149
+ await this.safeWrite(path3.join(destPath, writeContext.fileName), writeContext.dataToWrite);
4142
4150
  this.updateMetadata(writeContext.dataToWrite, writeContext.fileName, options.customMetadata);
4143
4151
  dataLeftOver = writeContext.dataLeftOver;
4144
4152
  }
@@ -4154,7 +4162,7 @@ var FileDatabase = class _FileDatabase {
4154
4162
  const { version, nextPage = false, pageSize, filename } = options;
4155
4163
  if (filename) {
4156
4164
  const destPath = this.getDestinationPath(version);
4157
- const filePath = path4.join(destPath, filename);
4165
+ const filePath = path3.join(destPath, filename);
4158
4166
  try {
4159
4167
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4160
4168
  return JSON.parse(rawData);
@@ -4166,7 +4174,7 @@ var FileDatabase = class _FileDatabase {
4166
4174
  const isNonPaginatedData = this.metadata.dataType === "text" || this.metadata.dataType === "xml" || this.metadata.dataType === "json-object";
4167
4175
  if (isNonPaginatedData) {
4168
4176
  const file = this.metadata.files[0];
4169
- const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4177
+ const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4170
4178
  try {
4171
4179
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4172
4180
  return deserializeData(rawData, this.metadata.dataType);
@@ -4204,7 +4212,7 @@ var FileDatabase = class _FileDatabase {
4204
4212
  let cumulativeRecords = currentFileOffset;
4205
4213
  for (let i = currentFileIndex; i < this.metadata.files.length && recordsRead < effectivePageSize; i++) {
4206
4214
  const file = this.metadata.files[i];
4207
- const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4215
+ const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
4208
4216
  try {
4209
4217
  const rawData = await fs3.promises.readFile(filePath, "utf8");
4210
4218
  const fileData = deserializeData(rawData, this.metadata.dataType);
@@ -4248,7 +4256,7 @@ var FileDatabase = class _FileDatabase {
4248
4256
  * Returns data file names (.json, .txt, .xml) excluding metadata.json.
4249
4257
  */
4250
4258
  async listFilenames() {
4251
- const destPath = this.versioned && this.currentVersion ? path4.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4259
+ const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4252
4260
  try {
4253
4261
  const entries = await fs3.promises.readdir(destPath, { withFileTypes: true });
4254
4262
  return entries.filter((e) => e.isFile() && e.name !== "metadata.json" && /\.(json|txt|xml)$/i.test(e.name)).map((e) => e.name);
@@ -4262,8 +4270,8 @@ var FileDatabase = class _FileDatabase {
4262
4270
  * Use with listFilenames() to manage individual files.
4263
4271
  */
4264
4272
  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);
4273
+ const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
4274
+ const filePath = path3.join(destPath, filename);
4267
4275
  try {
4268
4276
  await fs3.promises.unlink(filePath);
4269
4277
  } catch (err) {
@@ -4289,7 +4297,7 @@ var FileDatabase = class _FileDatabase {
4289
4297
  this.metadata.files.splice(idx, 1);
4290
4298
  this.metadata.totalRecords = Math.max(0, (this.metadata.totalRecords || 0) - recordsCount);
4291
4299
  const destPath = this.getDestinationPath();
4292
- const filePath = path4.join(destPath, filename);
4300
+ const filePath = path3.join(destPath, filename);
4293
4301
  try {
4294
4302
  await fs3.promises.unlink(filePath);
4295
4303
  } catch (err) {
@@ -4345,7 +4353,7 @@ var FileDatabase = class _FileDatabase {
4345
4353
  });
4346
4354
  if (matches) {
4347
4355
  const destPath = this.getDestinationPath();
4348
- const filePath = path4.join(destPath, fileEntry.fileName);
4356
+ const filePath = path3.join(destPath, fileEntry.fileName);
4349
4357
  const fileData = await fs3.promises.readFile(filePath, "utf8");
4350
4358
  const data = deserializeData(fileData, metadata.dataType || "json-object");
4351
4359
  results.push({
@@ -4368,7 +4376,7 @@ var FileDatabase = class _FileDatabase {
4368
4376
  });
4369
4377
  if (matches) {
4370
4378
  const destPath = this.getDestinationPath(version);
4371
- const filePath = path4.join(destPath, fileEntry.fileName);
4379
+ const filePath = path3.join(destPath, fileEntry.fileName);
4372
4380
  const fileData = await fs3.promises.readFile(filePath, "utf8");
4373
4381
  const data = deserializeData(fileData, metadata.dataType || "json-object");
4374
4382
  results.push({
@@ -4386,7 +4394,7 @@ var FileDatabase = class _FileDatabase {
4386
4394
  }
4387
4395
  };
4388
4396
 
4389
- // src/tasks/taskLogs.ts
4397
+ // src/tasks/taskLogs.js
4390
4398
  function getLogsState(context) {
4391
4399
  const holder = context;
4392
4400
  if (holder.__tasksLogsState) return holder.__tasksLogsState;
@@ -4439,6 +4447,89 @@ function getLogsState(context) {
4439
4447
  holder.__tasksLogsState = state;
4440
4448
  return state;
4441
4449
  }
4450
+ function ipcLogTargetKey(target) {
4451
+ const bp = target.basePath ?? "";
4452
+ const ns = target.namespace ?? "";
4453
+ return `${bp}::${ns}::${target.tableName}`;
4454
+ }
4455
+ function ipcFileLogsTableNameForSourceResource(source, resource) {
4456
+ const seg = (s) => {
4457
+ const t = String(s).trim().replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
4458
+ return t.length ? t : "x";
4459
+ };
4460
+ return `${seg(source)}/${seg(resource)}`;
4461
+ }
4462
+ async function readTaskIpcLogsSnapshot(context, options) {
4463
+ const holder = context;
4464
+ const basePath = holder.params?.get?.("tasksLogsBasePath") ?? "./data";
4465
+ const namespace = holder.params?.get?.("tasksLogsNamespace") ?? "tasks-logs";
4466
+ const tableName = ipcFileLogsTableNameForSourceResource(options.source, options.resource);
4467
+ const tail = Math.max(1, Math.min(1e4, Number(options.tail) > 0 ? Number(options.tail) : 100));
4468
+ const fd = new FileDatabase({
4469
+ basePath,
4470
+ namespace,
4471
+ tableName,
4472
+ versioned: true,
4473
+ useMetadata: true,
4474
+ maxVersions: 30,
4475
+ pageSize: 2e3,
4476
+ logger: holder.logger
4477
+ });
4478
+ const versions = await fd.getVersions();
4479
+ if (versions.length === 0) {
4480
+ return { records: [], latestTs: null };
4481
+ }
4482
+ const latest = versions[versions.length - 1];
4483
+ const raw = await fd.read({ version: latest });
4484
+ const arr = Array.isArray(raw) ? raw : [];
4485
+ let filtered = arr;
4486
+ if (options.afterTs && String(options.afterTs).trim()) {
4487
+ const cut = String(options.afterTs).trim();
4488
+ filtered = arr.filter((r) => r && typeof r.ts === "string" && String(r.ts) > cut);
4489
+ }
4490
+ let latestTs = null;
4491
+ for (const r of filtered) {
4492
+ const ts = typeof r?.ts === "string" ? String(r.ts) : null;
4493
+ if (ts && (!latestTs || ts > latestTs)) latestTs = ts;
4494
+ }
4495
+ const incremental = !!(options.afterTs && String(options.afterTs).trim());
4496
+ const maxReturn = incremental ? 1e4 : tail;
4497
+ const sliced = filtered.length > maxReturn ? filtered.slice(-maxReturn) : filtered;
4498
+ return { records: sliced, latestTs };
4499
+ }
4500
+ function getLogsStateForTarget(context, target) {
4501
+ const holder = context;
4502
+ const enabledRaw = holder.params?.get?.("tasksLogsEnabled");
4503
+ const enabled = enabledRaw === void 0 ? true : !!enabledRaw;
4504
+ if (!enabled) return null;
4505
+ if (!holder.__tasksLogsTargetStates) holder.__tasksLogsTargetStates = /* @__PURE__ */ new Map();
4506
+ const map = holder.__tasksLogsTargetStates;
4507
+ const key = ipcLogTargetKey(target);
4508
+ if (map.has(key)) return map.get(key);
4509
+ const basePath = target.basePath ?? (holder.params?.get?.("tasksLogsBasePath") || "./data");
4510
+ const namespace = target.namespace ?? (holder.params?.get?.("tasksLogsNamespace") || "tasks-logs");
4511
+ const maxVersionsRaw = Number(holder.params?.get?.("tasksLogsMaxVersions"));
4512
+ const pageSizeRaw = Number(holder.params?.get?.("tasksLogsPageSize"));
4513
+ const db = new FileDatabase({
4514
+ basePath,
4515
+ namespace,
4516
+ tableName: target.tableName,
4517
+ versioned: true,
4518
+ useMetadata: true,
4519
+ maxVersions: Number.isFinite(maxVersionsRaw) && maxVersionsRaw > 0 ? maxVersionsRaw : 20,
4520
+ pageSize: Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? pageSizeRaw : 2e3,
4521
+ logger: holder.logger
4522
+ });
4523
+ const state = {
4524
+ db,
4525
+ errorDb: null,
4526
+ queue: Promise.resolve(),
4527
+ initialized: false,
4528
+ errorInitialized: false
4529
+ };
4530
+ map.set(key, state);
4531
+ return state;
4532
+ }
4442
4533
  function isErrorPayload(payload) {
4443
4534
  if (!payload) return false;
4444
4535
  if (typeof payload === "object") {
@@ -4458,14 +4549,26 @@ function buildLogRecord(task, payload) {
4458
4549
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4459
4550
  opid: task.opid ?? null,
4460
4551
  taskId: task.id,
4461
- taskName: task.task,
4462
- target: task.target,
4552
+ taskName: task.name,
4553
+ target: task.service_group,
4463
4554
  source: typeof params.source === "string" ? params.source : null,
4464
4555
  resource: typeof params.resource === "string" ? params.resource : null,
4465
4556
  payload
4466
4557
  };
4467
4558
  }
4468
- function appendTaskIpcLog(context, task, payload) {
4559
+ function appendTaskIpcLog(context, task, payload, target) {
4560
+ if (target) {
4561
+ const state2 = getLogsStateForTarget(context, target);
4562
+ if (!state2?.db) return;
4563
+ const record2 = buildLogRecord(task, payload);
4564
+ state2.queue = state2.queue.then(async () => {
4565
+ await state2.db.write([record2], { forceNewVersion: !state2.initialized });
4566
+ state2.initialized = true;
4567
+ }).catch((error) => {
4568
+ context.logger.warn?.("[tasks] failed to persist IPC log entry (targeted):", error);
4569
+ });
4570
+ return;
4571
+ }
4469
4572
  const state = getLogsState(context);
4470
4573
  if (!state.db && !state.errorDb) return;
4471
4574
  const record = buildLogRecord(task, payload);
@@ -4483,83 +4586,280 @@ function appendTaskIpcLog(context, task, payload) {
4483
4586
  });
4484
4587
  }
4485
4588
 
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;
4589
+ // src/tasks/AbstractTask.js
4590
+ var AbstractTask = class _AbstractTask {
4591
+ /**
4592
+ * Whether `send-task` should wait for completion (and print a result
4593
+ * report) when no explicit `--wait` / `--noWait` flag is given. Defaults
4594
+ * to false; short-lived probe tasks (e.g. `ping`) override to true.
4595
+ *
4596
+ * @type {boolean}
4597
+ */
4598
+ static defaultWaitForResult = false;
4599
+ /**
4600
+ * @param {object} context Runner context (db, logger, params, emitter...).
4601
+ * @param {object} task Task row as claimed from the queue.
4602
+ */
4539
4603
  constructor(context, task) {
4540
4604
  this.context = context;
4541
4605
  this.task = task;
4542
4606
  }
4607
+ /**
4608
+ * Return a short reason string when the task should be deferred (e.g. "locked
4609
+ * by source"), or `false`/falsy when it is free to run. Default: always `false`.
4610
+ *
4611
+ * @returns {string | false | Promise<string | false>}
4612
+ */
4543
4613
  cantRunReason() {
4544
4614
  return false;
4545
4615
  }
4616
+ /**
4617
+ * Called by the runner when a stop has been requested. Subclasses running
4618
+ * long loops should flip a flag here and check it between iterations.
4619
+ *
4620
+ * @param {number} [_allowanceMs] Grace period the runner promises before hard exit.
4621
+ */
4546
4622
  requestStop(_allowanceMs) {
4547
4623
  }
4624
+ /**
4625
+ * Perform the task. Must be implemented by subclasses.
4626
+ *
4627
+ * @param {(progress: unknown) => Promise<void>} _reportProgress
4628
+ * Updates the DB `progress` column. Accepts any serializable value;
4629
+ * strings are stored verbatim, objects are JSON-stringified.
4630
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4631
+ */
4632
+ async run(_reportProgress) {
4633
+ throw new Error("AbstractTask.run must be implemented by subclass");
4634
+ }
4635
+ /**
4636
+ * Resolve a complete row payload for this task — envelope fields (queue,
4637
+ * priority, targeting, schedule…) plus the inner `params` blob produced by
4638
+ * {@link AbstractTask.resolveCustomParams}. Output shape matches
4639
+ * {@link enqueueTask}'s `options` argument, so the typical call is:
4640
+ *
4641
+ * const payload = await TaskClass.resolveParams(context, { name });
4642
+ * await enqueueTask(context, payload);
4643
+ *
4644
+ * Validation failures throw {@link ParamError} so the script aborts before
4645
+ * a malformed row hits the DB.
4646
+ *
4647
+ * @param {object} context
4648
+ * @param {Record<string, unknown>} [overrides] Partial overrides; takes precedence over CLI/env.
4649
+ * Recognised keys: `name`, `queueName`, `priority`, `serviceGroup`,
4650
+ * `serviceName`, `instanceNumber`, `serverName`, `opid`, `schedule`,
4651
+ * `nextRunAt`, plus `params` (object — overlay onto inner blob).
4652
+ * @returns {Promise<object>}
4653
+ */
4654
+ static async resolveParams(context, overrides = {}) {
4655
+ const main = _AbstractTask._resolveMainFields(context, overrides);
4656
+ const params = await this.resolveCustomParams(context, overrides);
4657
+ return { ...main, params };
4658
+ }
4659
+ /**
4660
+ * Resolve the inner JSON blob stored in the `params` column. Default
4661
+ * implementation passes through `--paramsJson` (parsed as a JSON object)
4662
+ * overlaid with `overrides.params` when supplied; returns `null` when
4663
+ * neither is provided.
4664
+ *
4665
+ * Subclasses with typed fields should override and call
4666
+ * {@link AbstractTask._mergeTypedParams} for layered CLI/env/JSON/override
4667
+ * resolution, then validate and throw {@link ParamError} on bad input.
4668
+ *
4669
+ * @param {object} context
4670
+ * @param {Record<string, unknown>} [overrides]
4671
+ * @returns {Promise<object|null>}
4672
+ */
4673
+ static async resolveCustomParams(context, overrides = {}) {
4674
+ return _AbstractTask._defaultParamsBlob(context, overrides);
4675
+ }
4676
+ /**
4677
+ * Read main task envelope fields from `context.params` (CLI/env), with
4678
+ * any matching key on `overrides` taking precedence. Internal; called by
4679
+ * {@link AbstractTask.resolveParams}.
4680
+ *
4681
+ * @param {object} context
4682
+ * @param {Record<string, unknown>} [overrides]
4683
+ * @returns {object}
4684
+ */
4685
+ static _resolveMainFields(context, overrides = {}) {
4686
+ const defs2 = {
4687
+ queueName: "string default tasks",
4688
+ priority: "number default 50",
4689
+ serviceGroup: "string",
4690
+ serviceName: "string",
4691
+ instanceNumber: "number",
4692
+ serverName: "string",
4693
+ opid: "string",
4694
+ schedule: "string"
4695
+ };
4696
+ const cli = context.params.getAllForModule("task-envelope", defs2);
4697
+ const name = emptyToUndef(overrides.name) ?? emptyToUndef(context.params.get("name", "string"));
4698
+ if (!name) {
4699
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
4700
+ }
4701
+ let instanceNumber;
4702
+ const rawInstance = overrides.instanceNumber ?? cli.instanceNumber;
4703
+ if (rawInstance !== void 0 && rawInstance !== null && String(rawInstance).trim() !== "") {
4704
+ const n = Number(rawInstance);
4705
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
4706
+ throw new ParamError("--instanceNumber must be a positive integer when set");
4707
+ }
4708
+ instanceNumber = n;
4709
+ } else {
4710
+ instanceNumber = null;
4711
+ }
4712
+ const priorityRaw = overrides.priority ?? cli.priority ?? 50;
4713
+ const priority = Number(priorityRaw);
4714
+ if (!Number.isFinite(priority)) {
4715
+ throw new ParamError(`--priority must be a number (got ${JSON.stringify(priorityRaw)})`);
4716
+ }
4717
+ return {
4718
+ name,
4719
+ queueName: overrides.queueName ?? emptyToUndef(cli.queueName) ?? "tasks",
4720
+ priority,
4721
+ serviceGroup: emptyToUndef(overrides.serviceGroup) ?? emptyToUndef(cli.serviceGroup) ?? null,
4722
+ serviceName: emptyToUndef(overrides.serviceName) ?? emptyToUndef(cli.serviceName) ?? null,
4723
+ instanceNumber,
4724
+ serverName: emptyToUndef(overrides.serverName) ?? emptyToUndef(cli.serverName) ?? null,
4725
+ opid: emptyToUndef(overrides.opid) ?? emptyToUndef(cli.opid) ?? null,
4726
+ schedule: emptyToUndef(overrides.schedule) ?? emptyToUndef(cli.schedule) ?? null,
4727
+ nextRunAt: overrides.nextRunAt ?? null
4728
+ };
4729
+ }
4730
+ /**
4731
+ * Default inner-params resolver: parses `--paramsJson` (must be a JSON
4732
+ * object), then overlays `overrides.params` on top. Returns `null` when
4733
+ * neither is provided.
4734
+ *
4735
+ * @param {object} context
4736
+ * @param {Record<string, unknown>} [overrides]
4737
+ * @returns {object|null}
4738
+ */
4739
+ static _defaultParamsBlob(context, overrides = {}) {
4740
+ const cli = context.params.getAllForModule("task-params", { paramsJson: "string" });
4741
+ const fromJson = parseParamsJson(cli.paramsJson);
4742
+ const fromOverride = pickParamsObject(overrides);
4743
+ if (!fromJson && !fromOverride) return null;
4744
+ return { ...fromJson ?? {}, ...fromOverride ?? {} };
4745
+ }
4746
+ /**
4747
+ * Helper for subclass `resolveCustomParams` overrides. Reads typed CLI
4748
+ * params (per `defs`) plus `--paramsJson` under a module namespace, then
4749
+ * merges them with explicit `overrides.params` in increasing priority:
4750
+ *
4751
+ * typed CLI flags → --paramsJson → overrides.params
4752
+ *
4753
+ * Undefined values are dropped so defaults declared in `defs` aren't
4754
+ * overwritten by missing-flag noise. Returns the merged object; the
4755
+ * caller is responsible for validation and throwing `ParamError`.
4756
+ *
4757
+ * @param {object} context
4758
+ * @param {string} moduleName Namespace for `--showUsedParams` grouping.
4759
+ * @param {Record<string, string>} defs Param defs in `getAllForModule` syntax.
4760
+ * @param {Record<string, unknown>} [overrides] As passed to `resolveCustomParams`.
4761
+ * @returns {Record<string, unknown>}
4762
+ */
4763
+ static _mergeTypedParams(context, moduleName, defs2, overrides = {}) {
4764
+ const fullDefs = { ...defs2, paramsJson: "string" };
4765
+ const cliRaw = context.params.getAllForModule(moduleName, fullDefs);
4766
+ const fromJson = parseParamsJson(cliRaw.paramsJson) ?? {};
4767
+ const fromCli = {};
4768
+ for (const [k, v] of Object.entries(cliRaw)) {
4769
+ if (k === "paramsJson") continue;
4770
+ if (v !== void 0 && v !== null) fromCli[k] = v;
4771
+ }
4772
+ const fromOverride = pickParamsObject(overrides) ?? {};
4773
+ return { ...fromCli, ...fromJson, ...fromOverride };
4774
+ }
4548
4775
  };
4776
+ function emptyToUndef(s) {
4777
+ if (s === void 0 || s === null) return void 0;
4778
+ if (typeof s !== "string") return s;
4779
+ const t = s.trim();
4780
+ return t.length ? t : void 0;
4781
+ }
4782
+ function parseParamsJson(raw) {
4783
+ if (raw == null) return null;
4784
+ const t = String(raw).trim();
4785
+ if (!t) return null;
4786
+ let parsed;
4787
+ try {
4788
+ parsed = JSON.parse(t);
4789
+ } catch (e) {
4790
+ throw new ParamError(`--paramsJson: not valid JSON: ${e?.message ?? String(e)}`);
4791
+ }
4792
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4793
+ throw new ParamError("--paramsJson must be a JSON object");
4794
+ }
4795
+ return parsed;
4796
+ }
4797
+ function pickParamsObject(overrides) {
4798
+ const p = overrides?.params;
4799
+ if (p && typeof p === "object" && !Array.isArray(p)) return p;
4800
+ return void 0;
4801
+ }
4549
4802
 
4550
- // src/tasks/coreTasks/TaskPing.ts
4551
- var TaskPing = class extends TaskMaster {
4803
+ // src/tasks/coreTasks/TaskPing.js
4804
+ var TaskPing = class extends AbstractTask {
4805
+ /** Default-wait so `send-task --name=ping` prints a result without `--wait`. */
4806
+ static defaultWaitForResult = true;
4807
+ /** Ping takes no params. */
4808
+ static async resolveCustomParams() {
4809
+ return null;
4810
+ }
4811
+ /**
4812
+ * @returns {Promise<{ success: true, results: "pong" }>}
4813
+ */
4552
4814
  async run() {
4553
4815
  this.context.logger.info?.(`[TaskPing] pong (${this.task.id})`);
4554
4816
  return { success: true, results: "pong" };
4555
4817
  }
4556
4818
  };
4557
4819
 
4558
- // src/tasks/coreTasks/TaskSampleProcess.ts
4559
- var TaskSampleProcess = class extends TaskMaster {
4560
- stopRequested = false;
4561
- stopAllowanceMs = 0;
4562
- stopDecisionLogged = false;
4820
+ // src/tasks/coreTasks/TaskSampleProcess.js
4821
+ var TaskSampleProcess = class extends AbstractTask {
4822
+ /**
4823
+ * @param {object} context
4824
+ * @param {Record<string, unknown>} [overrides]
4825
+ * @returns {Promise<{ total: number, delay: number, name?: string }>}
4826
+ */
4827
+ static async resolveCustomParams(context, overrides = {}) {
4828
+ const merged = AbstractTask._mergeTypedParams(context, "task-sample-process", {
4829
+ total: "number default 10",
4830
+ delay: "number default 1000",
4831
+ name: "string"
4832
+ }, overrides);
4833
+ const total = Number(merged.total);
4834
+ const delay = Number(merged.delay);
4835
+ if (!Number.isInteger(total) || total <= 0) {
4836
+ throw new ParamError(`sampleProcess: param "total" must be a positive integer (got ${JSON.stringify(merged.total)})`);
4837
+ }
4838
+ if (!Number.isInteger(delay) || delay < 0) {
4839
+ throw new ParamError(`sampleProcess: param "delay" must be an integer >= 0 (got ${JSON.stringify(merged.delay)})`);
4840
+ }
4841
+ const out = { total, delay };
4842
+ if (typeof merged.name === "string" && merged.name.trim()) {
4843
+ out.name = merged.name.trim();
4844
+ }
4845
+ return out;
4846
+ }
4847
+ /**
4848
+ * @param {object} context
4849
+ * @param {object} task
4850
+ */
4851
+ constructor(context, task) {
4852
+ super(context, task);
4853
+ this.stopRequested = false;
4854
+ this.stopAllowanceMs = 0;
4855
+ this.stopDecisionLogged = false;
4856
+ }
4857
+ /**
4858
+ * Runner-facing stop signal. Records the allowance window so the main loop
4859
+ * can decide per-iteration whether to finish or abort early.
4860
+ *
4861
+ * @param {number} allowanceMs
4862
+ */
4563
4863
  requestStop(allowanceMs) {
4564
4864
  this.stopRequested = true;
4565
4865
  this.stopAllowanceMs = Number.isFinite(allowanceMs) && allowanceMs > 0 ? allowanceMs : 0;
@@ -4567,6 +4867,14 @@ var TaskSampleProcess = class extends TaskMaster {
4567
4867
  `[TaskSampleProcess] stop signal received (${this.task.id}), allowanceMs=${this.stopAllowanceMs}`
4568
4868
  );
4569
4869
  }
4870
+ /**
4871
+ * Iterate `total` times, sleeping `delay` ms between ticks and reporting
4872
+ * progress every iteration. Validates params up front; invalid values short-
4873
+ * circuit to a structured failure without starting the loop.
4874
+ *
4875
+ * @param {(progress: object) => Promise<void>} reportProgress
4876
+ * @returns {Promise<{ success: boolean, results: unknown }>}
4877
+ */
4570
4878
  async run(reportProgress) {
4571
4879
  const totalRaw = this.task?.params?.total ?? 10;
4572
4880
  const delayRaw = this.task?.params?.delay ?? 1e3;
@@ -4648,7 +4956,7 @@ var TaskSampleProcess = class extends TaskMaster {
4648
4956
  }
4649
4957
  };
4650
4958
 
4651
- // src/tasks/coreTasks/TaskShellCommand.ts
4959
+ // src/tasks/coreTasks/TaskShellCommand.js
4652
4960
  import { spawn } from "child_process";
4653
4961
  function runShellCommand(command, cwd) {
4654
4962
  return new Promise((resolve2, reject) => {
@@ -4678,7 +4986,27 @@ function runShellCommand(command, cwd) {
4678
4986
  });
4679
4987
  });
4680
4988
  }
4681
- var TaskShellCommand = class extends TaskMaster {
4989
+ var TaskShellCommand = class extends AbstractTask {
4990
+ /**
4991
+ * @param {object} context
4992
+ * @param {Record<string, unknown>} [overrides]
4993
+ * @returns {Promise<{ command: string, cwd?: string }>}
4994
+ */
4995
+ static async resolveCustomParams(context, overrides = {}) {
4996
+ const merged = AbstractTask._mergeTypedParams(context, "task-shell", {
4997
+ command: "string",
4998
+ cwd: "string"
4999
+ }, overrides);
5000
+ const command = typeof merged.command === "string" ? merged.command.trim() : "";
5001
+ if (!command) {
5002
+ throw new ParamError('shellCommand: param "command" must be a non-empty string');
5003
+ }
5004
+ const cwd = typeof merged.cwd === "string" && merged.cwd.trim() ? merged.cwd.trim() : null;
5005
+ return cwd ? { command, cwd } : { command };
5006
+ }
5007
+ /**
5008
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5009
+ */
4682
5010
  async run() {
4683
5011
  const params = this.task?.params;
4684
5012
  const commandRaw = typeof params === "string" ? params : params?.command;
@@ -4727,7 +5055,7 @@ var TaskShellCommand = class extends TaskMaster {
4727
5055
  }
4728
5056
  };
4729
5057
 
4730
- // src/tasks/coreTasks/TaskSystemInfo.ts
5058
+ // src/tasks/coreTasks/TaskSystemInfo.js
4731
5059
  import os2 from "os";
4732
5060
  import fs4 from "fs/promises";
4733
5061
  function toGb(valueBytes) {
@@ -4747,7 +5075,16 @@ async function getDiskStats() {
4747
5075
  free: toGb(free)
4748
5076
  };
4749
5077
  }
4750
- var TaskSystemInfo = class extends TaskMaster {
5078
+ var TaskSystemInfo = class extends AbstractTask {
5079
+ /** Same UX expectation as `ping` — short probe, print the result. */
5080
+ static defaultWaitForResult = true;
5081
+ /** systemInfo takes no params. */
5082
+ static async resolveCustomParams() {
5083
+ return null;
5084
+ }
5085
+ /**
5086
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5087
+ */
4751
5088
  async run() {
4752
5089
  try {
4753
5090
  const totalMemory = os2.totalmem();
@@ -4799,8 +5136,31 @@ var TaskSystemInfo = class extends TaskMaster {
4799
5136
  }
4800
5137
  };
4801
5138
 
4802
- // src/tasks/coreTasks/TaskSumAB.ts
4803
- var TaskSumAB = class extends TaskMaster {
5139
+ // src/tasks/coreTasks/TaskSumAB.js
5140
+ var TaskSumAB = class extends AbstractTask {
5141
+ /** Short, deterministic — wait by default so callers see the sum. */
5142
+ static defaultWaitForResult = true;
5143
+ /**
5144
+ * @param {object} context
5145
+ * @param {Record<string, unknown>} [overrides]
5146
+ * @returns {Promise<{ a: number, b: number }>}
5147
+ */
5148
+ static async resolveCustomParams(context, overrides = {}) {
5149
+ const merged = AbstractTask._mergeTypedParams(context, "task-sumab", {
5150
+ a: "number",
5151
+ b: "number"
5152
+ }, overrides);
5153
+ if (typeof merged.a !== "number" || Number.isNaN(merged.a)) {
5154
+ throw new ParamError(`taskSumAB: param "a" must be a valid number (got ${JSON.stringify(merged.a)})`);
5155
+ }
5156
+ if (typeof merged.b !== "number" || Number.isNaN(merged.b)) {
5157
+ throw new ParamError(`taskSumAB: param "b" must be a valid number (got ${JSON.stringify(merged.b)})`);
5158
+ }
5159
+ return { a: merged.a, b: merged.b };
5160
+ }
5161
+ /**
5162
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5163
+ */
4804
5164
  async run() {
4805
5165
  const a = this.task?.params?.a;
4806
5166
  const b = this.task?.params?.b;
@@ -4831,8 +5191,46 @@ var TaskSumAB = class extends TaskMaster {
4831
5191
  }
4832
5192
  };
4833
5193
 
4834
- // src/tasks/coreTasks/TaskStopRunner.ts
4835
- var TaskStopRunner = class extends TaskMaster {
5194
+ // src/tasks/coreTasks/TaskStopRunner.js
5195
+ var TaskStopRunner = class extends AbstractTask {
5196
+ /**
5197
+ * Stop tasks must target a concrete instance — without `serviceName` the
5198
+ * row would race against any worker on the queue. Layered on top of the
5199
+ * envelope built by {@link AbstractTask.resolveParams}.
5200
+ *
5201
+ * @param {object} context
5202
+ * @param {Record<string, unknown>} [overrides]
5203
+ * @returns {Promise<object>}
5204
+ */
5205
+ static async resolveParams(context, overrides = {}) {
5206
+ const main = await super.resolveParams(context, overrides);
5207
+ if (!main.serviceName) {
5208
+ throw new ParamError(
5209
+ "stop/stopRunner requires --serviceName (registry instance name; optional --serviceGroup, --instanceNumber, --serverName to narrow targeting)"
5210
+ );
5211
+ }
5212
+ return main;
5213
+ }
5214
+ /**
5215
+ * @param {object} context
5216
+ * @param {Record<string, unknown>} [overrides]
5217
+ * @returns {Promise<{ allowanceMs: number }>}
5218
+ */
5219
+ static async resolveCustomParams(context, overrides = {}) {
5220
+ const merged = AbstractTask._mergeTypedParams(context, "task-stop", {
5221
+ allowanceMs: "number default 5000"
5222
+ }, overrides);
5223
+ const allowanceMs = Number(merged.allowanceMs);
5224
+ if (!Number.isFinite(allowanceMs) || allowanceMs < 0) {
5225
+ throw new ParamError(
5226
+ `stopRunner: allowanceMs must be a non-negative number (got ${JSON.stringify(merged.allowanceMs)})`
5227
+ );
5228
+ }
5229
+ return { allowanceMs };
5230
+ }
5231
+ /**
5232
+ * @returns {Promise<{ success: true, results: { stopRunner: true, allowanceMs: number, message: string } }>}
5233
+ */
4836
5234
  async run() {
4837
5235
  const allowanceMs = Number(this.task?.params?.allowanceMs ?? 5e3);
4838
5236
  this.context.logger.warn?.(`[TaskStopRunner] stop requested (allowanceMs=${allowanceMs})`);
@@ -4847,42 +5245,200 @@ var TaskStopRunner = class extends TaskMaster {
4847
5245
  }
4848
5246
  };
4849
5247
 
4850
- // src/tasks/TasksRegistry.ts
5248
+ // src/tasks/coreTasks/TaskGetLogs.js
5249
+ var TaskGetLogs = class extends AbstractTask {
5250
+ /**
5251
+ * @param {object} context
5252
+ * @param {Record<string, unknown>} [overrides]
5253
+ * @returns {Promise<{ source: string, resource: string, tail: number, afterTs?: string }>}
5254
+ */
5255
+ static async resolveCustomParams(context, overrides = {}) {
5256
+ const merged = AbstractTask._mergeTypedParams(context, "task-get-logs", {
5257
+ source: "string",
5258
+ resource: "string",
5259
+ tail: "number default 100",
5260
+ afterTs: "string"
5261
+ }, overrides);
5262
+ const source = typeof merged.source === "string" ? merged.source.trim() : "";
5263
+ const resource = typeof merged.resource === "string" ? merged.resource.trim() : "";
5264
+ if (!source) throw new ParamError('getLogs: param "source" is required');
5265
+ if (!resource) throw new ParamError('getLogs: param "resource" is required');
5266
+ let tail = Number(merged.tail);
5267
+ if (!Number.isFinite(tail) || tail < 1) tail = 100;
5268
+ tail = Math.min(1e4, Math.max(1, Math.floor(tail)));
5269
+ const out = { source, resource, tail };
5270
+ if (typeof merged.afterTs === "string" && merged.afterTs.trim()) {
5271
+ out.afterTs = merged.afterTs.trim();
5272
+ }
5273
+ return out;
5274
+ }
5275
+ /**
5276
+ * @param {unknown} _reportProgress Unused (single-shot read, no progress events).
5277
+ * @returns {Promise<{ success: boolean, results: unknown }>}
5278
+ */
5279
+ async run(_reportProgress) {
5280
+ const p = this.task.params ?? {};
5281
+ const source = String(p.source ?? "").trim();
5282
+ const resource = String(p.resource ?? "").trim();
5283
+ const tail = Math.max(1, Math.min(1e4, Number(p.tail) > 0 ? Number(p.tail) : 100));
5284
+ const afterTs = p.afterTs != null && String(p.afterTs).trim() ? String(p.afterTs).trim() : null;
5285
+ if (!source || !resource) {
5286
+ return {
5287
+ success: false,
5288
+ results: { error: 'getLogs requires params "source" and "resource"' }
5289
+ };
5290
+ }
5291
+ try {
5292
+ const { records, latestTs } = await readTaskIpcLogsSnapshot(this.context, {
5293
+ source,
5294
+ resource,
5295
+ tail,
5296
+ afterTs
5297
+ });
5298
+ return {
5299
+ success: true,
5300
+ results: { records, latestTs, source, resource }
5301
+ };
5302
+ } catch (e) {
5303
+ return {
5304
+ success: false,
5305
+ results: { error: e?.message ?? String(e) }
5306
+ };
5307
+ }
5308
+ }
5309
+ };
5310
+
5311
+ // src/tasks/TasksRegistry.js
4851
5312
  var TasksRegistry = class _TasksRegistry {
4852
- map = {};
5313
+ /**
5314
+ * @param {Record<string, Function>} [initial] Optional seed entries to copy in.
5315
+ */
4853
5316
  constructor(initial) {
5317
+ this.map = {};
4854
5318
  if (initial) {
4855
5319
  this.addMany(initial);
4856
5320
  }
4857
5321
  }
5322
+ /**
5323
+ * Build a registry pre-populated with every core task plus legacy aliases.
5324
+ * Prefer this over `new TasksRegistry()` for anything that wants `ping`/`stop`/etc.
5325
+ *
5326
+ * @returns {TasksRegistry}
5327
+ */
4858
5328
  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);
5329
+ 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
5330
  }
5331
+ /**
5332
+ * Register a single task class under a name. Overwrites any previous entry.
5333
+ *
5334
+ * @param {string} taskName
5335
+ * @param {Function} taskClass Subclass of `AbstractTask`.
5336
+ * @returns {this}
5337
+ */
4861
5338
  add(taskName, taskClass) {
4862
5339
  this.map[taskName] = taskClass;
4863
5340
  return this;
4864
5341
  }
5342
+ /**
5343
+ * Bulk-register a name → class map. Later calls override earlier ones.
5344
+ *
5345
+ * @param {Record<string, Function>} entries
5346
+ * @returns {this}
5347
+ */
4865
5348
  addMany(entries) {
4866
5349
  for (const [name, klass] of Object.entries(entries)) {
4867
5350
  this.add(name, klass);
4868
5351
  }
4869
5352
  return this;
4870
5353
  }
5354
+ /**
5355
+ * Look up a task class by name. Returns `undefined` when the name is unknown;
5356
+ * the runner treats that as "some other worker may handle this" and skips.
5357
+ *
5358
+ * @param {string} taskName
5359
+ * @returns {Function | undefined}
5360
+ */
4871
5361
  get(taskName) {
4872
5362
  return this.map[taskName];
4873
5363
  }
5364
+ /**
5365
+ * Strict variant of {@link get}: throws {@link ParamError} (with the list
5366
+ * of supported names) when `taskName` is unknown. Use from enqueuer code
5367
+ * paths where an unknown name is a hard CLI/programmer error.
5368
+ *
5369
+ * @param {string} taskName
5370
+ * @returns {Function}
5371
+ */
5372
+ requireClass(taskName) {
5373
+ const TaskClass = taskName ? this.map[taskName] : void 0;
5374
+ if (!TaskClass) {
5375
+ const supported = this.listSupportedTasks().join(", ") || "(none)";
5376
+ throw new ParamError(
5377
+ `Unknown task "${taskName ?? ""}". Supported on this registry: ${supported}`
5378
+ );
5379
+ }
5380
+ return TaskClass;
5381
+ }
5382
+ /**
5383
+ * Dispatcher used by `send-task` / programmatic enqueuers: pick `name`
5384
+ * from `overrides` or `context.params`, look up the class, and delegate
5385
+ * to its static {@link AbstractTask.resolveParams} with `name` seeded into
5386
+ * the overrides. The returned object is shaped for {@link enqueueTask}.
5387
+ *
5388
+ * Validation failures (unknown task, missing required custom params, etc.)
5389
+ * surface as {@link ParamError} so the caller aborts cleanly before any
5390
+ * row is inserted.
5391
+ *
5392
+ * @param {object} context
5393
+ * @param {Record<string, unknown>} [overrides]
5394
+ * @returns {Promise<object>}
5395
+ */
5396
+ async resolveTaskParams(context, overrides = {}) {
5397
+ const overrideName = typeof overrides.name === "string" ? overrides.name.trim() : overrides.name;
5398
+ const fromCli = context.params.get("name", "string");
5399
+ const cliName = typeof fromCli === "string" ? fromCli.trim() : fromCli;
5400
+ const name = overrideName || cliName;
5401
+ if (!name) {
5402
+ throw new ParamError("Task --name is required (e.g. ping, stop, dummyHarvest)");
5403
+ }
5404
+ const TaskClass = this.requireClass(name);
5405
+ return TaskClass.resolveParams(context, { ...overrides, name });
5406
+ }
5407
+ /**
5408
+ * Names of every registered task, sorted alphabetically (useful for CLI output
5409
+ * and allowlist sanity checks).
5410
+ *
5411
+ * @returns {string[]}
5412
+ */
4874
5413
  listSupportedTasks() {
4875
5414
  return Object.keys(this.map).sort();
4876
5415
  }
5416
+ /**
5417
+ * Shallow copy of the internal map, for handing to `addMany` on another registry
5418
+ * or for serialization.
5419
+ *
5420
+ * @returns {Record<string, Function>}
5421
+ */
4877
5422
  toObject() {
4878
5423
  return { ...this.map };
4879
5424
  }
4880
5425
  };
4881
5426
 
4882
- // src/tasks/taskScriptRunner.ts
5427
+ // src/tasks/serviceTaskAllowlist.js
5428
+ function normalizeAllowedTasks(value) {
5429
+ if (!value) return void 0;
5430
+ if (Array.isArray(value)) {
5431
+ const out2 = value.map((v) => String(v).trim()).filter(Boolean);
5432
+ return out2.length ? out2 : void 0;
5433
+ }
5434
+ const out = String(value).split(",").map((v) => v.trim()).filter(Boolean);
5435
+ return out.length ? out : void 0;
5436
+ }
5437
+
5438
+ // src/tasks/taskScriptRunner.js
4883
5439
  import { spawn as spawn2 } from "child_process";
4884
5440
 
4885
- // src/tasks/index.ts
5441
+ // src/tasks/index.js
4886
5442
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
4887
5443
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
4888
5444
  function getDb3(context) {
@@ -4897,15 +5453,6 @@ function normalizeRegistry(registry) {
4897
5453
  if (registry instanceof TasksRegistry) return registry;
4898
5454
  return new TasksRegistry().addMany(registry);
4899
5455
  }
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
5456
  async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs) {
4910
5457
  context.logger.warn?.(`[tasks] signaling ${runningTaskInstances.size} running task(s) to stop`);
4911
5458
  for (const [, taskInstance] of runningTaskInstances) {
@@ -4921,18 +5468,20 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
4921
5468
  }
4922
5469
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
4923
5470
  const db = getDb3(context);
4924
- const taskName = row.task;
5471
+ const taskName = row.name;
4925
5472
  const TaskClass = registry.get(taskName);
4926
- const { paused_at: _pausedAt, ...rowForHistory } = row;
4927
5473
  if (!TaskClass) {
4928
5474
  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
- });
5475
+ await db(historyTable).insert(
5476
+ taskHistoryInsertFromQueueRow(row, {
5477
+ completed_at: /* @__PURE__ */ new Date(),
5478
+ success: false,
5479
+ status: "failed",
5480
+ status_changed_at: db.fn.now(),
5481
+ params: toJsonColumn(row.params),
5482
+ results: toJsonColumn(err)
5483
+ })
5484
+ );
4936
5485
  if (row.schedule) {
4937
5486
  await db(tasksTable).where({ id: row.id }).update({
4938
5487
  started_at: null,
@@ -4940,7 +5489,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4940
5489
  success: false,
4941
5490
  results: toJsonColumn(err),
4942
5491
  past_due: null,
4943
- paused_at: db.fn.now(),
5492
+ status: "paused",
5493
+ status_changed_at: db.fn.now(),
4944
5494
  progress: LOCKED_BY_ERROR_MESSAGE
4945
5495
  });
4946
5496
  } else {
@@ -4967,13 +5517,16 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4967
5517
  } finally {
4968
5518
  runningTaskInstances.delete(row.id);
4969
5519
  }
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
- });
5520
+ await db(historyTable).insert(
5521
+ taskHistoryInsertFromQueueRow(row, {
5522
+ completed_at: /* @__PURE__ */ new Date(),
5523
+ success,
5524
+ status: success ? "completed" : "failed",
5525
+ status_changed_at: db.fn.now(),
5526
+ params: toJsonColumn(row.params),
5527
+ results: toJsonColumn(results)
5528
+ })
5529
+ );
4977
5530
  if (!success) {
4978
5531
  const dbName = String(context?.params?.get?.("dbName") || "local");
4979
5532
  const tableName = String(context?.params?.get?.("table") || "tasks");
@@ -4988,19 +5541,32 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4988
5541
  const rerunCommand = results && typeof results === "object" && results.rerunCommand ? results.rerunCommand : fallbackRecoverCommand;
4989
5542
  appendTaskIpcLog(context, row, {
4990
5543
  level: "error",
4991
- message: `[tasks] task failed: ${row.task} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
5544
+ message: `[tasks] task failed: ${row.name} id=${row.id}. Once problem is fixed, re-run: ${rerunCommand}`,
4992
5545
  details: results
4993
5546
  });
4994
5547
  }
4995
5548
  if (row.schedule) {
4996
5549
  if (success) {
5550
+ let nextRunAt = null;
5551
+ try {
5552
+ nextRunAt = nextTimeMatch(row.schedule, /* @__PURE__ */ new Date());
5553
+ } catch (e) {
5554
+ context.logger?.warn?.(`[tasks] nextTimeMatch after success for task ${row.id}: ${e?.message ?? String(e)}`);
5555
+ }
4997
5556
  await db(tasksTable).where({ id: row.id }).update({
4998
5557
  started_at: null,
4999
5558
  completed_at: /* @__PURE__ */ new Date(),
5000
5559
  success,
5001
5560
  results: toJsonColumn(results),
5002
5561
  progress: null,
5003
- past_due: null
5562
+ past_due: null,
5563
+ status: "idle",
5564
+ status_changed_at: db.fn.now(),
5565
+ next_run_at: nextRunAt,
5566
+ // Claim overwrites these; clear so idle rows stay “any worker” (see claimNextRunnableTask).
5567
+ service_name: null,
5568
+ server_name: null,
5569
+ instance_number: null
5004
5570
  });
5005
5571
  } else {
5006
5572
  await db(tasksTable).where({ id: row.id }).update({
@@ -5008,7 +5574,8 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5008
5574
  completed_at: /* @__PURE__ */ new Date(),
5009
5575
  success,
5010
5576
  results: toJsonColumn(results),
5011
- paused_at: db.fn.now(),
5577
+ status: "paused",
5578
+ status_changed_at: db.fn.now(),
5012
5579
  progress: LOCKED_BY_ERROR_MESSAGE,
5013
5580
  past_due: null
5014
5581
  });
@@ -5020,58 +5587,93 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
5020
5587
  const stopAllowanceMs = stopRunnerRequested ? Number(results.allowanceMs ?? 5e3) : 0;
5021
5588
  return { stopRunnerRequested, stopAllowanceMs };
5022
5589
  }
5023
- async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
5590
+ function shuffleTaskRowsInPlace(rows) {
5591
+ for (let i = rows.length - 1; i > 0; i--) {
5592
+ const j = Math.floor(Math.random() * (i + 1));
5593
+ const t = rows[i];
5594
+ rows[i] = rows[j];
5595
+ rows[j] = t;
5596
+ }
5597
+ }
5598
+ async function claimNextRunnableTask(context, tasksTable, serviceGroup, registry, scanLimit, taskNames, runnerIdentity) {
5024
5599
  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);
5600
+ let query = db(tasksTable).where({ status: "idle" }).where(function() {
5601
+ this.whereNull("service_group").orWhere({ service_group: serviceGroup });
5602
+ }).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
5603
  if (taskNames && taskNames.length > 0) {
5027
- query = query.whereIn("task", taskNames);
5604
+ query = query.whereIn("name", taskNames);
5605
+ }
5606
+ if (runnerIdentity) {
5607
+ query = query.where(function() {
5608
+ this.whereNull("service_name").orWhere({ service_name: runnerIdentity.service_name });
5609
+ }).where(function() {
5610
+ this.whereNull("instance_number").orWhere({ instance_number: runnerIdentity.instance_number });
5611
+ }).where(function() {
5612
+ this.whereNull("server_name").orWhere({ server_name: runnerIdentity.server_name });
5613
+ });
5614
+ } else {
5615
+ query = query.whereNull("service_name").whereNull("instance_number").whereNull("server_name");
5028
5616
  }
5029
5617
  const candidates = await query;
5618
+ shuffleTaskRowsInPlace(candidates);
5030
5619
  for (const row of candidates) {
5031
5620
  if (!row.past_due && row.schedule && !timeMatcher(row.schedule)) {
5032
5621
  continue;
5033
5622
  }
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;
5623
+ const TaskClass = registry.get(row.name);
5624
+ if (!TaskClass) {
5625
+ continue;
5626
+ }
5627
+ const taskInstance = new TaskClass(context, row);
5628
+ const reason = taskInstance.cantRunReason ? await taskInstance.cantRunReason() : null;
5629
+ if (reason) {
5630
+ if (!row.past_due) {
5631
+ await db(tasksTable).where({ id: row.id }).update({
5632
+ past_due: db.fn.now(),
5633
+ progress: String(reason)
5634
+ });
5046
5635
  }
5636
+ continue;
5637
+ }
5638
+ const claimPatch = {
5639
+ started_at: db.fn.now(),
5640
+ status: "running",
5641
+ status_changed_at: db.fn.now()
5642
+ };
5643
+ if (runnerIdentity) {
5644
+ claimPatch.service_name = runnerIdentity.service_name;
5645
+ claimPatch.server_name = runnerIdentity.server_name;
5646
+ claimPatch.instance_number = runnerIdentity.instance_number;
5047
5647
  }
5048
- const updated = await db(tasksTable).where({ id: row.id }).whereNull("started_at").whereNull("paused_at").update({ started_at: db.fn.now() }).returning("*");
5648
+ const updated = await db(tasksTable).where({ id: row.id, status: "idle" }).update(claimPatch).returning("*");
5049
5649
  const claimed = Array.isArray(updated) ? updated[0] : null;
5050
5650
  if (claimed) return claimed;
5051
5651
  }
5052
5652
  return null;
5053
5653
  }
5054
5654
  async function runTasksLoop(context, options) {
5055
- const queue = options.queue ?? "tasks";
5655
+ const queueName = options.queueName ?? "tasks";
5056
5656
  const target = options.target;
5057
5657
  const pollMs = options.pollMs ?? 1e3;
5058
- const maxParallel = options.maxParallel ?? 1;
5658
+ const claimJitterMs = options.claimJitterMs ?? 0;
5659
+ const maxParallel = options.maxParallel ?? 32;
5059
5660
  const scanLimit = options.scanLimit ?? 100;
5060
5661
  const allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5061
5662
  const registry = normalizeRegistry(options.registry);
5062
- const { tasksTable, historyTable } = queueToTableNames(queue);
5663
+ const { tasksTable, historyTable } = queueToTableNames(queueName);
5063
5664
  if (!target) throw new Error("runTasksLoop: target is required");
5665
+ context.tasksQueueName = queueName;
5064
5666
  const runningPromises = /* @__PURE__ */ new Set();
5065
5667
  const runningTaskInstances = /* @__PURE__ */ new Map();
5066
5668
  let runningStopControlPromise = null;
5067
5669
  let stopRequested = false;
5068
5670
  let stopAllowanceMs = 5e3;
5069
- context.__tasksRunnerStop = false;
5671
+ context.tasksRunnerStop = false;
5070
5672
  let registryReg = null;
5071
5673
  let registryInterval = null;
5674
+ let runnerIdentity = null;
5072
5675
  const hbGroup = options.runnerServiceGroup?.trim();
5073
5676
  if (hbGroup) {
5074
- const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
5075
5677
  const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
5076
5678
  const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
5077
5679
  const defaultMeta = {
@@ -5079,16 +5681,21 @@ async function runTasksLoop(context, options) {
5079
5681
  allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
5080
5682
  };
5081
5683
  registryReg = await registerInServicesRegistry(context, {
5082
- queue,
5684
+ queueName,
5083
5685
  target,
5084
5686
  serviceGroup: hbGroup,
5085
5687
  serviceName: options.runnerServiceName,
5086
- identityDir,
5688
+ instanceNumber: options.runnerInstanceNumber,
5087
5689
  staleMs,
5088
5690
  groupMaxInstances: options.runnerGroupMaxInstances,
5089
5691
  enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
5090
5692
  metadata: options.runnerMetadata ?? defaultMeta
5091
5693
  });
5694
+ runnerIdentity = {
5695
+ service_name: registryReg.serviceName,
5696
+ server_name: os3.hostname(),
5697
+ instance_number: registryReg.instanceNumber
5698
+ };
5092
5699
  registryInterval = setInterval(() => {
5093
5700
  void touchServicesRegistry(context, registryReg).catch((err) => {
5094
5701
  context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
@@ -5096,7 +5703,7 @@ async function runTasksLoop(context, options) {
5096
5703
  }, hbIntervalMs);
5097
5704
  }
5098
5705
  try {
5099
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
5706
+ while (!context.isStop() && !stopRequested && context.tasksRunnerStop !== true) {
5100
5707
  if (!runningStopControlPromise) {
5101
5708
  const claimedStopTask = await claimNextRunnableTask(
5102
5709
  context,
@@ -5104,7 +5711,8 @@ async function runTasksLoop(context, options) {
5104
5711
  target,
5105
5712
  registry,
5106
5713
  10,
5107
- ["stopRunner", "stop"]
5714
+ ["stopRunner", "stop"],
5715
+ runnerIdentity
5108
5716
  );
5109
5717
  if (claimedStopTask) {
5110
5718
  runningStopControlPromise = executeClaimedTask(
@@ -5118,7 +5726,7 @@ async function runTasksLoop(context, options) {
5118
5726
  if (outcome.stopRunnerRequested && !stopRequested) {
5119
5727
  stopRequested = true;
5120
5728
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5121
- context.__tasksRunnerStop = true;
5729
+ context.tasksRunnerStop = true;
5122
5730
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5123
5731
  }
5124
5732
  }).finally(() => {
@@ -5126,6 +5734,9 @@ async function runTasksLoop(context, options) {
5126
5734
  });
5127
5735
  }
5128
5736
  }
5737
+ if (claimJitterMs > 0) {
5738
+ await sleepMs(Math.floor(Math.random() * (claimJitterMs + 1)));
5739
+ }
5129
5740
  while (runningPromises.size < maxParallel) {
5130
5741
  const claimed = await claimNextRunnableTask(
5131
5742
  context,
@@ -5133,14 +5744,15 @@ async function runTasksLoop(context, options) {
5133
5744
  target,
5134
5745
  registry,
5135
5746
  scanLimit,
5136
- allowedTasks
5747
+ allowedTasks,
5748
+ runnerIdentity
5137
5749
  );
5138
5750
  if (!claimed) break;
5139
5751
  const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
5140
5752
  if (outcome.stopRunnerRequested && !stopRequested) {
5141
5753
  stopRequested = true;
5142
5754
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5143
- context.__tasksRunnerStop = true;
5755
+ context.tasksRunnerStop = true;
5144
5756
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5145
5757
  }
5146
5758
  }).finally(() => {
@@ -5148,7 +5760,16 @@ async function runTasksLoop(context, options) {
5148
5760
  });
5149
5761
  runningPromises.add(p);
5150
5762
  }
5151
- await sleepMs(pollMs);
5763
+ const wakePromises = [...runningPromises];
5764
+ if (runningStopControlPromise) {
5765
+ wakePromises.push(runningStopControlPromise);
5766
+ }
5767
+ if (wakePromises.length === 0) {
5768
+ await sleepMs(pollMs);
5769
+ } else {
5770
+ const safe = wakePromises.map((p) => p.catch(() => void 0));
5771
+ await Promise.race([sleepMs(pollMs), Promise.race(safe)]);
5772
+ }
5152
5773
  }
5153
5774
  if (context.isStop() && !stopRequested) {
5154
5775
  await signalRunningTasksStop(context, runningTaskInstances, 5e3);
@@ -5183,71 +5804,88 @@ async function runTasksLoop(context, options) {
5183
5804
  }
5184
5805
  }
5185
5806
  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;
5807
+ /**
5808
+ * @param {object} context
5809
+ * @param {{
5810
+ * queueName?: string,
5811
+ * target?: string,
5812
+ * recreateTaskTables?: boolean,
5813
+ * pollMs?: number,
5814
+ * claimJitterMs?: number,
5815
+ * maxParallel?: number,
5816
+ * scanLimit?: number,
5817
+ * allowedTasks?: string | string[],
5818
+ * registry?: TasksRegistry | Record<string, Function>,
5819
+ * runnerServiceGroup?: string,
5820
+ * runnerServiceName?: string,
5821
+ * runnerInstanceNumber?: number,
5822
+ * runnerHeartbeatIntervalMs?: number,
5823
+ * runnerHeartbeatStaleMs?: number,
5824
+ * runnerGroupMaxInstances?: number,
5825
+ * runnerEnforceMaxInstances?: boolean,
5826
+ * runnerMetadata?: Record<string, unknown>,
5827
+ * }} [options]
5828
+ */
5203
5829
  constructor(context, options = {}) {
5204
5830
  this.context = context;
5205
- this.queue = options.queue ?? "tasks";
5831
+ this.queueName = options.queueName ?? "tasks";
5206
5832
  this.target = options.target ?? "localRunner";
5207
5833
  this.recreateTaskTables = options.recreateTaskTables ?? false;
5208
5834
  this.pollMs = options.pollMs ?? 1e3;
5835
+ this.claimJitterMs = options.claimJitterMs ?? 0;
5209
5836
  this.maxParallel = options.maxParallel ?? 1;
5210
5837
  this.scanLimit = options.scanLimit ?? 100;
5211
5838
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5212
5839
  this.registry = normalizeRegistry(options.registry);
5213
5840
  this.runnerServiceGroup = options.runnerServiceGroup;
5214
5841
  this.runnerServiceName = options.runnerServiceName;
5215
- this.runnerIdentityDir = options.runnerIdentityDir;
5842
+ this.runnerInstanceNumber = options.runnerInstanceNumber;
5216
5843
  this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
5217
5844
  this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
5218
5845
  this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
5219
5846
  this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
5220
5847
  this.runnerMetadata = options.runnerMetadata;
5221
5848
  }
5849
+ /**
5850
+ * Preferred factory: reads defaults from `context.params` (module namespace
5851
+ * `"tasks"`), then overlays explicit `options`. Keeps CLI flags, env vars,
5852
+ * and inline options in one consistent resolver.
5853
+ *
5854
+ * @param {object} context
5855
+ * @param {ConstructorParameters<typeof TasksManager>[1]} [options]
5856
+ * @returns {TasksManager}
5857
+ */
5222
5858
  static init(context, options = {}) {
5223
5859
  const defs2 = {
5224
5860
  table: "string default tasks",
5225
5861
  target: "string default localRunner",
5226
5862
  recreateTaskTables: "boolean default false",
5227
5863
  pollMs: "number default 1000",
5864
+ claimJitterMs: "number default 0",
5228
5865
  maxParallel: "number default 1",
5229
5866
  scanLimit: "number default 100",
5230
5867
  allowedTasks: "string",
5231
5868
  runnerServiceGroup: "string",
5232
5869
  runnerServiceName: "string",
5233
- runnerIdentityDir: "string default ./data/runner-identities",
5870
+ runnerInstanceNumber: "number",
5234
5871
  runnerHeartbeatIntervalMs: "number default 10000",
5235
5872
  runnerHeartbeatStaleMs: "number default 45000",
5236
5873
  runnerGroupMaxInstances: "number",
5237
5874
  runnerEnforceMaxInstances: "boolean default true"
5238
5875
  };
5239
- const discovered = context.params.getAllForModule(defs2);
5876
+ const discovered = context.params.getAllForModule("tasks", defs2);
5240
5877
  const resolved = {
5241
- queue: discovered.table,
5878
+ queueName: discovered.table,
5242
5879
  target: discovered.target,
5243
5880
  recreateTaskTables: discovered.recreateTaskTables,
5244
5881
  pollMs: discovered.pollMs,
5882
+ claimJitterMs: discovered.claimJitterMs,
5245
5883
  maxParallel: discovered.maxParallel,
5246
5884
  scanLimit: discovered.scanLimit,
5247
5885
  allowedTasks: discovered.allowedTasks,
5248
5886
  runnerServiceGroup: discovered.runnerServiceGroup,
5249
5887
  runnerServiceName: discovered.runnerServiceName,
5250
- runnerIdentityDir: discovered.runnerIdentityDir,
5888
+ runnerInstanceNumber: discovered.runnerInstanceNumber,
5251
5889
  runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
5252
5890
  runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
5253
5891
  runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
@@ -5256,24 +5894,39 @@ var TasksManager = class _TasksManager {
5256
5894
  };
5257
5895
  return new _TasksManager(context, resolved);
5258
5896
  }
5897
+ /**
5898
+ * Idempotently ensure the three backing tables exist for this queue.
5899
+ *
5900
+ * @param {{ recreate?: boolean }} [options]
5901
+ * @returns {Promise<void>}
5902
+ */
5259
5903
  async ensureTaskTables(options = {}) {
5260
5904
  await ensureTaskTables(this.context, {
5261
- queue: this.queue,
5905
+ queueName: this.queueName,
5262
5906
  recreate: options.recreate ?? this.recreateTaskTables
5263
5907
  });
5264
5908
  }
5909
+ /**
5910
+ * Start the runner loop using this manager's resolved config. Per-call
5911
+ * options override the stored defaults, but `runnerMetadata` still falls
5912
+ * through when omitted.
5913
+ *
5914
+ * @param {Partial<ConstructorParameters<typeof TasksManager>[1]>} [options]
5915
+ * @returns {Promise<void>}
5916
+ */
5265
5917
  async runTasksLoop(options = {}) {
5266
5918
  await runTasksLoop(this.context, {
5267
- queue: options.queue ?? this.queue,
5919
+ queueName: options.queueName ?? this.queueName,
5268
5920
  target: options.target ?? this.target,
5269
5921
  pollMs: options.pollMs ?? this.pollMs,
5922
+ claimJitterMs: options.claimJitterMs ?? this.claimJitterMs,
5270
5923
  maxParallel: options.maxParallel ?? this.maxParallel,
5271
5924
  scanLimit: options.scanLimit ?? this.scanLimit,
5272
5925
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
5273
5926
  registry: options.registry ?? this.registry,
5274
5927
  runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
5275
5928
  runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
5276
- runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
5929
+ runnerInstanceNumber: options.runnerInstanceNumber ?? this.runnerInstanceNumber,
5277
5930
  runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
5278
5931
  runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
5279
5932
  runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
@@ -5283,9 +5936,8 @@ var TasksManager = class _TasksManager {
5283
5936
  }
5284
5937
  };
5285
5938
 
5286
- // src/scripts/cli-runner.ts
5939
+ // src/scripts/cli-runner.js
5287
5940
  var defs = {
5288
- dbName: "string default local",
5289
5941
  tasksModule: "string"
5290
5942
  };
5291
5943
  async function loadTasksModule(modulePath) {
@@ -5297,11 +5949,8 @@ async function loadTasksModule(modulePath) {
5297
5949
  return imported.tasksRegistry;
5298
5950
  }
5299
5951
  var flow = async (context) => {
5300
- const {
5301
- dbName,
5302
- tasksModule
5303
- } = context.params.getAll(defs);
5304
- const db = await dbInit(context, dbName);
5952
+ const { tasksModule } = context.params.getAll(defs);
5953
+ const db = await Db.init(context);
5305
5954
  context.db = db;
5306
5955
  const registry = new TasksRegistry().addMany(defaultTasksRegistry.toObject());
5307
5956
  if (tasksModule) {