@web-auto/webauto 0.1.4 → 0.1.7

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 (174) hide show
  1. package/apps/desktop-console/default-settings.json +2 -2
  2. package/apps/desktop-console/dist/main/index.mjs +983 -128
  3. package/apps/desktop-console/dist/main/preload.mjs +7 -0
  4. package/apps/desktop-console/dist/renderer/index.html +622 -50
  5. package/apps/desktop-console/dist/renderer/index.js +2423 -469
  6. package/apps/desktop-console/dist/renderer/run.mts +6 -5
  7. package/apps/desktop-console/entry/ui-cli.mjs +672 -0
  8. package/apps/desktop-console/entry/ui-console.mjs +416 -29
  9. package/apps/webauto/entry/account.mjs +89 -53
  10. package/apps/webauto/entry/browser-status.mjs +7 -10
  11. package/apps/webauto/entry/lib/account-detect.mjs +254 -28
  12. package/apps/webauto/entry/lib/account-store.mjs +219 -30
  13. package/apps/webauto/entry/lib/bus-publish.mjs +63 -0
  14. package/apps/webauto/entry/lib/camo-cli.mjs +93 -0
  15. package/apps/webauto/entry/lib/profilepool.mjs +14 -5
  16. package/apps/webauto/entry/lib/quota-status.mjs +23 -0
  17. package/apps/webauto/entry/lib/schedule-store.mjs +1068 -0
  18. package/apps/webauto/entry/profilepool.mjs +106 -17
  19. package/apps/webauto/entry/schedule.mjs +612 -0
  20. package/apps/webauto/entry/weibo-unified.mjs +134 -0
  21. package/apps/webauto/entry/xhs-install.mjs +256 -31
  22. package/apps/webauto/entry/xhs-status.mjs +5 -2
  23. package/apps/webauto/entry/xhs-unified.mjs +631 -98
  24. package/apps/webauto/resources/container-library/weibo/weibo_detail_page/comment_item/container.json +40 -0
  25. package/apps/webauto/resources/container-library/weibo/weibo_detail_page/reply_expand_button/container.json +38 -0
  26. package/apps/webauto/resources/container-library/weibo/weibo_detail_page/reply_list/container.json +37 -0
  27. package/apps/webauto/resources/container-library/weibo/weibo_search_page/container.json +8 -3
  28. package/apps/webauto/resources/container-library/weibo/weibo_search_page/login_anchor/container.json +30 -0
  29. package/apps/webauto/resources/container-library/weibo/weibo_search_page/search_bar/container.json +47 -0
  30. package/apps/webauto/resources/container-library/weibo/weibo_search_page/search_button/container.json +39 -0
  31. package/bin/camoufox-cli.mjs +61 -0
  32. package/bin/webauto.mjs +301 -54
  33. package/dist/modules/camo-backend/src/index.js +49 -1
  34. package/dist/modules/camo-backend/src/internal/BrowserSession.js +572 -3
  35. package/dist/modules/camo-backend/src/internal/SessionManager.js +13 -1
  36. package/dist/modules/camo-backend/src/internal/storage-paths.js +6 -0
  37. package/dist/modules/collection-manager/bloom-filter.js +91 -0
  38. package/dist/modules/collection-manager/date-utils.js +275 -0
  39. package/dist/modules/collection-manager/index.js +258 -0
  40. package/dist/modules/collection-manager/storage.js +195 -0
  41. package/dist/modules/collection-manager/types.js +47 -0
  42. package/dist/modules/logging/src/index.js +1 -1
  43. package/dist/modules/process-registry/index.js +230 -0
  44. package/dist/modules/rate-limiter/index.js +242 -0
  45. package/dist/modules/workflow/blocks/ExecuteWeiboSearchBlock.js +128 -0
  46. package/dist/modules/workflow/blocks/PersistXhsNoteBlock.js +7 -3
  47. package/dist/modules/workflow/blocks/RenderMarkdown.js +4 -1
  48. package/dist/modules/workflow/blocks/WeiboCollectCommentsBlock.js +282 -0
  49. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +283 -0
  50. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +208 -0
  51. package/dist/modules/workflow/blocks/WeiboCollectTimelineListBlock.js +128 -0
  52. package/dist/modules/workflow/blocks/WeiboCollectUserPostsListBlock.js +127 -0
  53. package/dist/modules/workflow/blocks/helpers/downloadPaths.js +21 -0
  54. package/dist/modules/workflow/config/workflowRegistry.js +2 -0
  55. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +47 -0
  56. package/dist/modules/workflow/src/runner.js +6 -0
  57. package/dist/modules/xiaohongshu/app/src/blocks/Phase34PersistDetailBlock.js +4 -0
  58. package/dist/modules/xiaohongshu/app/src/blocks/Phase3InteractBlock.js +2 -2
  59. package/dist/modules/xiaohongshu/app/src/blocks/helpers/sharding.js +123 -0
  60. package/dist/modules/xiaohongshu/app/src/container-registry/src/index.d.ts +37 -0
  61. package/dist/modules/xiaohongshu/app/src/container-registry/src/index.js +184 -0
  62. package/dist/modules/xiaohongshu/app/src/workflow/blocks/AnchorVerificationBlock.d.ts +31 -0
  63. package/dist/modules/xiaohongshu/app/src/workflow/blocks/AnchorVerificationBlock.js +71 -0
  64. package/dist/modules/xiaohongshu/app/src/workflow/blocks/DetectPageStateBlock.d.ts +48 -0
  65. package/dist/modules/xiaohongshu/app/src/workflow/blocks/DetectPageStateBlock.js +259 -0
  66. package/dist/modules/xiaohongshu/app/src/workflow/blocks/ErrorRecoveryBlock.d.ts +28 -0
  67. package/dist/modules/xiaohongshu/app/src/workflow/blocks/ErrorRecoveryBlock.js +319 -0
  68. package/dist/modules/xiaohongshu/app/src/workflow/blocks/WaitSearchPermitBlock.d.ts +36 -0
  69. package/dist/modules/xiaohongshu/app/src/workflow/blocks/WaitSearchPermitBlock.js +162 -0
  70. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/containerAnchors.d.ts +36 -0
  71. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/containerAnchors.js +301 -0
  72. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/operationLogger.d.ts +29 -0
  73. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/operationLogger.js +195 -0
  74. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/searchPageState.d.ts +25 -0
  75. package/dist/modules/xiaohongshu/app/src/workflow/blocks/helpers/searchPageState.js +164 -0
  76. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/MatchCommentsBlock.d.ts +66 -0
  77. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/MatchCommentsBlock.js +139 -0
  78. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1EnsureServicesBlock.d.ts +16 -0
  79. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1EnsureServicesBlock.js +36 -0
  80. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1MonitorCookieBlock.d.ts +27 -0
  81. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1MonitorCookieBlock.js +213 -0
  82. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1StartProfileBlock.d.ts +18 -0
  83. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase1StartProfileBlock.js +121 -0
  84. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase2CollectLinksBlock.d.ts +34 -0
  85. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase2CollectLinksBlock.js +1249 -0
  86. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase2SearchBlock.d.ts +17 -0
  87. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase2SearchBlock.js +703 -0
  88. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CloseDetailBlock.d.ts +15 -0
  89. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CloseDetailBlock.js +41 -0
  90. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CloseTabsBlock.d.ts +26 -0
  91. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CloseTabsBlock.js +44 -0
  92. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CollectCommentsBlock.d.ts +29 -0
  93. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34CollectCommentsBlock.js +150 -0
  94. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ExtractDetailBlock.d.ts +38 -0
  95. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ExtractDetailBlock.js +117 -0
  96. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34OpenDetailBlock.d.ts +30 -0
  97. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34OpenDetailBlock.js +102 -0
  98. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34OpenTabsBlock.d.ts +23 -0
  99. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34OpenTabsBlock.js +109 -0
  100. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34PersistDetailBlock.d.ts +32 -0
  101. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34PersistDetailBlock.js +117 -0
  102. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ProcessSingleNoteBlock.d.ts +35 -0
  103. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ProcessSingleNoteBlock.js +114 -0
  104. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ValidateLinksBlock.d.ts +34 -0
  105. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase34ValidateLinksBlock.js +90 -0
  106. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase3InteractBlock.d.ts +111 -0
  107. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase3InteractBlock.js +1009 -0
  108. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase4MultiTabHarvestBlock.d.ts +20 -0
  109. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/Phase4MultiTabHarvestBlock.js +233 -0
  110. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/ReplyInteractBlock.d.ts +48 -0
  111. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +291 -0
  112. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/XhsDiscoverFallbackBlock.d.ts +23 -0
  113. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/XhsDiscoverFallbackBlock.js +240 -0
  114. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/commentMatchDsl.d.ts +55 -0
  115. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/commentMatchDsl.js +126 -0
  116. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/commentMatcher.d.ts +21 -0
  117. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/commentMatcher.js +99 -0
  118. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/evidence.d.ts +5 -0
  119. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/evidence.js +27 -0
  120. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/sharding.d.ts +37 -0
  121. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/sharding.js +165 -0
  122. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/xhsComments.d.ts +33 -0
  123. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/blocks/helpers/xhsComments.js +270 -0
  124. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/index.d.ts +9 -0
  125. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/index.js +9 -0
  126. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/utils/checkpoints.d.ts +50 -0
  127. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/utils/checkpoints.js +222 -0
  128. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/utils/controllerAction.d.ts +10 -0
  129. package/dist/modules/xiaohongshu/app/src/xiaohongshu/app/src/utils/controllerAction.js +43 -0
  130. package/dist/services/shared/serviceProcessLogger.js +1 -1
  131. package/dist/services/unified-api/server.js +105 -11
  132. package/modules/camo-backend/src/index.ts +46 -1
  133. package/modules/camo-backend/src/internal/BrowserSession.ts +619 -3
  134. package/modules/camo-backend/src/internal/SessionManager.ts +12 -1
  135. package/modules/camo-backend/src/internal/storage-paths.ts +5 -0
  136. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +38 -2
  137. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +47 -2
  138. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +94 -11
  139. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +208 -2
  140. package/modules/camo-runtime/src/autoscript/runtime.mjs +7 -1
  141. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +76 -43
  142. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +75 -1
  143. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +71 -4
  144. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +183 -27
  145. package/modules/collection-manager/bloom-filter.ts +112 -0
  146. package/modules/collection-manager/date-utils.ts +316 -0
  147. package/modules/collection-manager/index.ts +309 -0
  148. package/modules/collection-manager/package.json +10 -0
  149. package/modules/collection-manager/storage.ts +174 -0
  150. package/modules/collection-manager/types.ts +156 -0
  151. package/modules/logging/src/index.ts +1 -1
  152. package/modules/process-registry/index.ts +284 -0
  153. package/modules/rate-limiter/index.ts +322 -0
  154. package/modules/state/src/paths.ts +9 -1
  155. package/modules/task-scheduler/index.ts +293 -0
  156. package/modules/workflow/blocks/ExecuteWeiboSearchBlock.ts +167 -0
  157. package/modules/workflow/blocks/PersistXhsNoteBlock.ts +7 -3
  158. package/modules/workflow/blocks/RenderMarkdown.ts +4 -1
  159. package/modules/workflow/blocks/WeiboCollectCommentsBlock.ts +339 -0
  160. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +338 -0
  161. package/modules/workflow/blocks/helpers/downloadPaths.ts +16 -0
  162. package/modules/workflow/config/workflowRegistry.ts +2 -0
  163. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +47 -0
  164. package/modules/workflow/src/runner.ts +6 -0
  165. package/modules/xiaohongshu/app/src/blocks/Phase1StartProfileBlock.ts +1 -1
  166. package/modules/xiaohongshu/app/src/blocks/Phase34PersistDetailBlock.ts +4 -0
  167. package/modules/xiaohongshu/app/src/blocks/Phase3InteractBlock.ts +2 -3
  168. package/modules/xiaohongshu/app/src/blocks/helpers/sharding.ts +152 -0
  169. package/package.json +13 -4
  170. package/scripts/postinstall-resources.mjs +62 -0
  171. package/scripts/test/run-coverage.mjs +76 -0
  172. package/scripts/weibo/search.ts +49 -0
  173. package/services/shared/serviceProcessLogger.ts +1 -1
  174. package/services/unified-api/server.ts +98 -12
@@ -0,0 +1,1068 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const INDEX_FILE = 'index.json';
6
+ const DEFAULT_COMMAND_TYPE = 'xhs-unified';
7
+ const SUPPORTED_COMMAND_TYPES = [
8
+ 'xhs-unified',
9
+ 'weibo-timeline',
10
+ 'weibo-search',
11
+ 'weibo-monitor',
12
+ '1688-search',
13
+ ];
14
+ const DEFAULT_INTERVAL_MINUTES = 30;
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ const POLICY_FILE = 'policy.json';
17
+ const LOCKS_DIR = 'locks';
18
+ const DAEMON_LEASE_FILE = 'daemon-lease.json';
19
+ const CLAIM_MUTEX_FILE = 'claim-mutex.json';
20
+ const TASK_CLAIMS_DIR = 'task-claims';
21
+ const RESOURCE_CLAIMS_DIR = 'resource-claims';
22
+ const DEFAULT_TASK_LEASE_MS = 30 * 60 * 1000;
23
+ const DEFAULT_DAEMON_LEASE_MS = 2 * 60 * 1000;
24
+ const DEFAULT_SCHEDULER_POLICY = Object.freeze({
25
+ maxConcurrency: 1,
26
+ maxConcurrencyByPlatform: {},
27
+ resourceMutex: {
28
+ enabled: true,
29
+ dimensions: ['account', 'profile'],
30
+ allowCrossPlatformSameAccount: false,
31
+ },
32
+ });
33
+
34
+ function nowIso() {
35
+ return new Date().toISOString();
36
+ }
37
+
38
+ function readJson(filePath, fallback) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
41
+ } catch {
42
+ return fallback;
43
+ }
44
+ }
45
+
46
+ function writeJson(filePath, payload) {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
49
+ }
50
+
51
+ function normalizeText(value) {
52
+ const text = String(value ?? '').trim();
53
+ return text || null;
54
+ }
55
+
56
+ function normalizeBoolean(value, fallback = false) {
57
+ if (value === undefined || value === null) return fallback;
58
+ if (typeof value === 'boolean') return value;
59
+ const text = String(value).trim().toLowerCase();
60
+ if (!text) return fallback;
61
+ if (text === '1' || text === 'true' || text === 'yes') return true;
62
+ if (text === '0' || text === 'false' || text === 'no') return false;
63
+ return fallback;
64
+ }
65
+
66
+ function normalizePositiveInt(value, fallback) {
67
+ const n = Number(value);
68
+ if (!Number.isFinite(n)) return fallback;
69
+ return Math.max(1, Math.floor(n));
70
+ }
71
+
72
+ function normalizeMaxRuns(value, fallback = null) {
73
+ if (value === undefined) return fallback;
74
+ if (value === null || value === '') return null;
75
+ const n = Number(value);
76
+ if (!Number.isFinite(n)) return fallback;
77
+ if (n <= 0) return null;
78
+ return Math.max(1, Math.floor(n));
79
+ }
80
+
81
+ function resolvePortableRoot() {
82
+ const root = String(process.env.WEBAUTO_PORTABLE_ROOT || process.env.WEBAUTO_ROOT || '').trim();
83
+ return root ? path.join(root, '.webauto') : '';
84
+ }
85
+
86
+ function resolveWebautoRoot() {
87
+ const portableRoot = resolvePortableRoot();
88
+ if (portableRoot) return portableRoot;
89
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
90
+ return path.join(home, '.webauto');
91
+ }
92
+
93
+ export function resolveSchedulesRoot() {
94
+ const explicit = String(process.env.WEBAUTO_PATHS_SCHEDULES || '').trim();
95
+ if (explicit) return explicit;
96
+ return path.join(resolveWebautoRoot(), 'schedules');
97
+ }
98
+
99
+ function resolveIndexPath() {
100
+ return path.join(resolveSchedulesRoot(), INDEX_FILE);
101
+ }
102
+
103
+ function resolvePolicyPath() {
104
+ return path.join(resolveSchedulesRoot(), POLICY_FILE);
105
+ }
106
+
107
+ function resolveLocksRoot() {
108
+ return path.join(resolveSchedulesRoot(), LOCKS_DIR);
109
+ }
110
+
111
+ function resolveDaemonLeasePath() {
112
+ return path.join(resolveLocksRoot(), DAEMON_LEASE_FILE);
113
+ }
114
+
115
+ function resolveClaimMutexPath() {
116
+ return path.join(resolveLocksRoot(), CLAIM_MUTEX_FILE);
117
+ }
118
+
119
+ function resolveTaskClaimsRoot() {
120
+ return path.join(resolveLocksRoot(), TASK_CLAIMS_DIR);
121
+ }
122
+
123
+ function resolveResourceClaimsRoot() {
124
+ return path.join(resolveLocksRoot(), RESOURCE_CLAIMS_DIR);
125
+ }
126
+
127
+ function encodeLockKey(value) {
128
+ const raw = Buffer.from(String(value || ''), 'utf8').toString('base64');
129
+ return raw.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') || 'empty';
130
+ }
131
+
132
+ function resolveTaskClaimPath(taskId) {
133
+ return path.join(resolveTaskClaimsRoot(), `${encodeLockKey(taskId)}.json`);
134
+ }
135
+
136
+ function resolveResourceClaimPath(resourceKey) {
137
+ return path.join(resolveResourceClaimsRoot(), `${encodeLockKey(resourceKey)}.json`);
138
+ }
139
+
140
+ function parseIsoTs(value) {
141
+ const ts = Date.parse(String(value || ''));
142
+ return Number.isFinite(ts) ? ts : null;
143
+ }
144
+
145
+ function buildLeasePayload({ ownerId, runToken = null, leaseMs, meta = {}, nowMs = Date.now() }) {
146
+ const ttl = Math.max(1_000, Math.floor(Number(leaseMs) || DEFAULT_TASK_LEASE_MS));
147
+ const iso = new Date(nowMs).toISOString();
148
+ return {
149
+ ownerId: String(ownerId || ''),
150
+ runToken: normalizeText(runToken),
151
+ createdAt: iso,
152
+ updatedAt: iso,
153
+ expiresAt: new Date(nowMs + ttl).toISOString(),
154
+ ...meta,
155
+ };
156
+ }
157
+
158
+ function isLeaseExpired(payload, nowMs = Date.now()) {
159
+ const expiryTs = parseIsoTs(payload?.expiresAt);
160
+ return !Number.isFinite(expiryTs) || expiryTs <= nowMs;
161
+ }
162
+
163
+ function safeUnlink(filePath) {
164
+ try {
165
+ fs.unlinkSync(filePath);
166
+ } catch {
167
+ // ignore
168
+ }
169
+ }
170
+
171
+ function readLease(filePath) {
172
+ return readJson(filePath, null);
173
+ }
174
+
175
+ function writeLease(filePath, payload) {
176
+ writeJson(filePath, payload);
177
+ }
178
+
179
+ function tryCreateLease(filePath, payload) {
180
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
181
+ let fd = null;
182
+ try {
183
+ fd = fs.openSync(filePath, 'wx');
184
+ fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
185
+ return { ok: true, lease: payload };
186
+ } catch (error) {
187
+ if (error?.code === 'EEXIST') return { ok: false, reason: 'exists' };
188
+ throw error;
189
+ } finally {
190
+ if (fd !== null) {
191
+ try {
192
+ fs.closeSync(fd);
193
+ } catch {
194
+ // ignore
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ function leaseOwnedBy(payload, ownerId, runToken = null) {
201
+ if (!payload || String(payload.ownerId || '') !== String(ownerId || '')) return false;
202
+ if (runToken === null || runToken === undefined || runToken === '') return true;
203
+ return String(payload.runToken || '') === String(runToken || '');
204
+ }
205
+
206
+ function acquireLease(filePath, { ownerId, runToken = null, leaseMs, meta = {}, nowMs = Date.now() }) {
207
+ const nextLease = buildLeasePayload({ ownerId, runToken, leaseMs, meta, nowMs });
208
+ const created = tryCreateLease(filePath, nextLease);
209
+ if (created.ok) {
210
+ return { ok: true, acquired: true, renewed: false, lease: created.lease };
211
+ }
212
+
213
+ const existing = readLease(filePath);
214
+ if (leaseOwnedBy(existing, ownerId, runToken)) {
215
+ const renewed = {
216
+ ...existing,
217
+ ...nextLease,
218
+ createdAt: existing?.createdAt || nextLease.createdAt,
219
+ };
220
+ writeLease(filePath, renewed);
221
+ return { ok: true, acquired: false, renewed: true, lease: renewed };
222
+ }
223
+
224
+ if (!isLeaseExpired(existing, nowMs)) {
225
+ return { ok: false, reason: 'busy', lease: existing };
226
+ }
227
+
228
+ safeUnlink(filePath);
229
+ const reclaimed = tryCreateLease(filePath, nextLease);
230
+ if (reclaimed.ok) {
231
+ return { ok: true, acquired: true, renewed: false, lease: reclaimed.lease, reclaimed: true };
232
+ }
233
+ return { ok: false, reason: 'busy', lease: readLease(filePath) };
234
+ }
235
+
236
+ function renewLease(filePath, { ownerId, runToken = null, leaseMs, nowMs = Date.now() }) {
237
+ const existing = readLease(filePath);
238
+ if (!existing) return { ok: false, reason: 'missing' };
239
+ if (!leaseOwnedBy(existing, ownerId, runToken)) return { ok: false, reason: 'owner_mismatch', lease: existing };
240
+ const nextLease = {
241
+ ...existing,
242
+ updatedAt: new Date(nowMs).toISOString(),
243
+ expiresAt: new Date(nowMs + Math.max(1_000, Math.floor(Number(leaseMs) || DEFAULT_TASK_LEASE_MS))).toISOString(),
244
+ };
245
+ writeLease(filePath, nextLease);
246
+ return { ok: true, lease: nextLease };
247
+ }
248
+
249
+ function releaseLease(filePath, { ownerId, runToken = null }) {
250
+ const existing = readLease(filePath);
251
+ if (!existing) return { ok: true, released: false, reason: 'missing' };
252
+ if (!leaseOwnedBy(existing, ownerId, runToken)) return { ok: false, released: false, reason: 'owner_mismatch', lease: existing };
253
+ safeUnlink(filePath);
254
+ return { ok: true, released: true, lease: existing };
255
+ }
256
+
257
+ function listActiveLeases(rootDir, nowMs = Date.now()) {
258
+ if (!fs.existsSync(rootDir)) return [];
259
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
260
+ const leases = [];
261
+ for (const entry of entries) {
262
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
263
+ const fullPath = path.join(rootDir, entry.name);
264
+ const payload = readLease(fullPath);
265
+ if (!payload || isLeaseExpired(payload, nowMs)) {
266
+ safeUnlink(fullPath);
267
+ continue;
268
+ }
269
+ leases.push({ path: fullPath, lease: payload });
270
+ }
271
+ return leases;
272
+ }
273
+
274
+ function normalizeDimensions(raw) {
275
+ const input = Array.isArray(raw) ? raw : [];
276
+ const out = [];
277
+ for (const item of input) {
278
+ const value = String(item || '').trim().toLowerCase();
279
+ if (!value) continue;
280
+ if (!out.includes(value)) out.push(value);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function normalizeConcurrencyMap(raw) {
286
+ const source = raw && typeof raw === 'object' ? raw : {};
287
+ const out = {};
288
+ for (const [key, value] of Object.entries(source)) {
289
+ const name = String(key || '').trim().toLowerCase();
290
+ if (!name) continue;
291
+ const count = Number(value);
292
+ if (Number.isFinite(count) && count > 0) out[name] = Math.floor(count);
293
+ }
294
+ return out;
295
+ }
296
+
297
+ function extractTaskPlatform(task) {
298
+ const commandType = String(task?.commandType || DEFAULT_COMMAND_TYPE).trim();
299
+ const platform = commandType.split('-')[0];
300
+ return String(platform || 'unknown').toLowerCase();
301
+ }
302
+
303
+ function extractTaskIdentity(task) {
304
+ const argv = task?.commandArgv && typeof task.commandArgv === 'object' ? task.commandArgv : {};
305
+ return {
306
+ platform: extractTaskPlatform(task),
307
+ profile: normalizeText(argv.profile),
308
+ account: normalizeText(argv.accountId ?? argv['account-id'] ?? argv.account ?? argv.uid ?? argv.userId ?? argv['user-id']),
309
+ };
310
+ }
311
+
312
+ function buildResourceKeys(task, policy) {
313
+ const mutex = policy?.resourceMutex || {};
314
+ if (mutex.enabled === false) return [];
315
+ const dimensions = normalizeDimensions(mutex.dimensions || DEFAULT_SCHEDULER_POLICY.resourceMutex.dimensions);
316
+ const identity = extractTaskIdentity(task);
317
+ const keys = [];
318
+ for (const dimension of dimensions) {
319
+ if (dimension === 'profile' && identity.profile) {
320
+ keys.push(`profile:${identity.profile}`);
321
+ continue;
322
+ }
323
+ if (dimension === 'account' && identity.account) {
324
+ if (mutex.allowCrossPlatformSameAccount === true && identity.platform) {
325
+ keys.push(`account:${identity.account}:platform:${identity.platform}`);
326
+ } else {
327
+ keys.push(`account:${identity.account}`);
328
+ }
329
+ }
330
+ }
331
+ return [...new Set(keys)].sort();
332
+ }
333
+
334
+ export function normalizeSchedulerPolicy(input = {}) {
335
+ const source = input && typeof input === 'object' && !Array.isArray(input)
336
+ ? input
337
+ : {};
338
+ const mutexInput = source.resourceMutex && typeof source.resourceMutex === 'object'
339
+ ? source.resourceMutex
340
+ : {};
341
+ const dimensions = normalizeDimensions(mutexInput.dimensions || DEFAULT_SCHEDULER_POLICY.resourceMutex.dimensions);
342
+ const maxConcurrency = normalizePositiveInt(
343
+ source.maxConcurrency ?? source.maxGlobalConcurrency,
344
+ DEFAULT_SCHEDULER_POLICY.maxConcurrency,
345
+ );
346
+ return {
347
+ maxConcurrency,
348
+ maxConcurrencyByPlatform: normalizeConcurrencyMap(source.maxConcurrencyByPlatform),
349
+ resourceMutex: {
350
+ enabled: normalizeBoolean(mutexInput.enabled, DEFAULT_SCHEDULER_POLICY.resourceMutex.enabled),
351
+ dimensions: dimensions.length > 0 ? dimensions : [...DEFAULT_SCHEDULER_POLICY.resourceMutex.dimensions],
352
+ allowCrossPlatformSameAccount: normalizeBoolean(
353
+ mutexInput.allowCrossPlatformSameAccount,
354
+ DEFAULT_SCHEDULER_POLICY.resourceMutex.allowCrossPlatformSameAccount,
355
+ ),
356
+ },
357
+ };
358
+ }
359
+
360
+ export function getSchedulerPolicy() {
361
+ const envRaw = normalizeText(process.env.WEBAUTO_SCHEDULE_POLICY_JSON);
362
+ if (envRaw) {
363
+ try {
364
+ return normalizeSchedulerPolicy(JSON.parse(envRaw));
365
+ } catch {
366
+ return normalizeSchedulerPolicy(DEFAULT_SCHEDULER_POLICY);
367
+ }
368
+ }
369
+ return normalizeSchedulerPolicy(readJson(resolvePolicyPath(), DEFAULT_SCHEDULER_POLICY));
370
+ }
371
+
372
+ export function setSchedulerPolicy(input = {}) {
373
+ const policy = normalizeSchedulerPolicy(input);
374
+ writeJson(resolvePolicyPath(), policy);
375
+ return policy;
376
+ }
377
+
378
+ function checkConcurrencyAllowance(task, policy, nowMs = Date.now()) {
379
+ const active = listActiveLeases(resolveTaskClaimsRoot(), nowMs).map((item) => item.lease);
380
+ const globalLimit = normalizePositiveInt(policy?.maxConcurrency, DEFAULT_SCHEDULER_POLICY.maxConcurrency);
381
+ if (active.length >= globalLimit) {
382
+ return { ok: false, reason: 'max_concurrency', activeCount: active.length, limit: globalLimit };
383
+ }
384
+ const platformLimits = normalizeConcurrencyMap(policy?.maxConcurrencyByPlatform);
385
+ const platform = extractTaskPlatform(task);
386
+ const limit = Number(platformLimits[platform] || 0);
387
+ if (limit > 0) {
388
+ const used = active.filter((lease) => String(lease?.platform || '').trim().toLowerCase() === platform).length;
389
+ if (used >= limit) {
390
+ return {
391
+ ok: false,
392
+ reason: 'platform_max_concurrency',
393
+ platform,
394
+ activeCount: used,
395
+ limit,
396
+ };
397
+ }
398
+ }
399
+ return { ok: true };
400
+ }
401
+
402
+ function loadIndex() {
403
+ const fallback = {
404
+ version: 1,
405
+ nextSeq: 1,
406
+ updatedAt: null,
407
+ tasks: [],
408
+ };
409
+ const raw = readJson(resolveIndexPath(), fallback);
410
+ const tasks = Array.isArray(raw?.tasks) ? raw.tasks.filter((task) => normalizeText(task?.id)) : [];
411
+ const maxSeq = tasks.reduce((acc, task) => Math.max(acc, Number(task?.seq) || 0), 0);
412
+ const nextSeq = Number.isFinite(Number(raw?.nextSeq)) && Number(raw?.nextSeq) > maxSeq
413
+ ? Number(raw.nextSeq)
414
+ : maxSeq + 1;
415
+ return {
416
+ version: 1,
417
+ nextSeq,
418
+ updatedAt: raw?.updatedAt || null,
419
+ tasks,
420
+ };
421
+ }
422
+
423
+ function saveIndex(index) {
424
+ const payload = {
425
+ version: 1,
426
+ nextSeq: Number(index?.nextSeq) || 1,
427
+ updatedAt: nowIso(),
428
+ tasks: Array.isArray(index?.tasks) ? index.tasks : [],
429
+ };
430
+ writeJson(resolveIndexPath(), payload);
431
+ return payload;
432
+ }
433
+
434
+ function formatSeq(seq) {
435
+ return String(seq).padStart(4, '0');
436
+ }
437
+
438
+ function normalizeScheduleType(value) {
439
+ const text = String(value || 'interval').trim().toLowerCase();
440
+ if (text === 'once') return 'once';
441
+ if (text === 'daily') return 'daily';
442
+ if (text === 'weekly') return 'weekly';
443
+ return 'interval';
444
+ }
445
+
446
+ function normalizeCommandArgv(value) {
447
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
448
+ return { ...value };
449
+ }
450
+
451
+ function validateCommand(task) {
452
+ const commandType = String(task.commandType || DEFAULT_COMMAND_TYPE).trim();
453
+ if (!SUPPORTED_COMMAND_TYPES.includes(commandType)) {
454
+ throw new Error(`unsupported commandType: ${commandType}. Supported: ${SUPPORTED_COMMAND_TYPES.join(', ')}`);
455
+ }
456
+ const argv = task.commandArgv && typeof task.commandArgv === 'object' ? task.commandArgv : {};
457
+ const platform = commandType.split('-')[0];
458
+ if (platform === 'xhs') {
459
+ validateXhsCommand(argv);
460
+ } else if (platform === 'weibo' || platform === '1688') {
461
+ validateGenericCommand(argv, platform, commandType);
462
+ }
463
+ }
464
+
465
+ function validateXhsCommand(argv) {
466
+ const keyword = normalizeText(argv.keyword || argv.k);
467
+ const profile = normalizeText(argv.profile);
468
+ const profiles = normalizeText(argv.profiles);
469
+ const profilepool = normalizeText(argv.profilepool);
470
+ if (!keyword) throw new Error('task command argv missing keyword');
471
+ if (!profile && !profiles && !profilepool) {
472
+ throw new Error('task command argv missing profile/profiles/profilepool');
473
+ }
474
+ }
475
+
476
+ function validateGenericCommand(argv, platform, commandType = '') {
477
+ const keyword = normalizeText(argv.keyword || argv.k);
478
+ const profile = normalizeText(argv.profile);
479
+ const profiles = normalizeText(argv.profiles);
480
+ const profilepool = normalizeText(argv.profilepool);
481
+ let weiboTaskType = '';
482
+ if (platform === 'weibo') {
483
+ weiboTaskType = String(argv['task-type'] || argv.taskType || '').trim();
484
+ if (!weiboTaskType && commandType === 'weibo-timeline') weiboTaskType = 'timeline';
485
+ if (!weiboTaskType && commandType === 'weibo-search') weiboTaskType = 'search';
486
+ if (!weiboTaskType && commandType === 'weibo-monitor') weiboTaskType = 'monitor';
487
+ if (!['timeline', 'search', 'monitor'].includes(weiboTaskType)) {
488
+ throw new Error(`weibo task requires task-type: timeline|search|monitor`);
489
+ }
490
+ const userId = normalizeText(argv['user-id'] || argv.userId);
491
+ if (weiboTaskType === 'monitor' && !userId) {
492
+ throw new Error('weibo monitor task requires user-id');
493
+ }
494
+ }
495
+ if (!keyword && (platform !== 'weibo' || weiboTaskType === 'search')) {
496
+ throw new Error('task command argv missing keyword');
497
+ }
498
+ if (!profile && !profiles && !profilepool) {
499
+ throw new Error('task command argv missing profile/profiles/profilepool');
500
+ }
501
+ }
502
+
503
+ function normalizeScheduleFields(input = {}, fallback = {}) {
504
+ const scheduleType = normalizeScheduleType(
505
+ input.scheduleType
506
+ ?? input.type
507
+ ?? fallback.scheduleType
508
+ ?? fallback.type
509
+ ?? 'interval',
510
+ );
511
+ const intervalMinutes = normalizePositiveInt(
512
+ input.intervalMinutes
513
+ ?? input.everyMinutes
514
+ ?? fallback.intervalMinutes
515
+ ?? fallback.everyMinutes
516
+ ?? DEFAULT_INTERVAL_MINUTES,
517
+ DEFAULT_INTERVAL_MINUTES,
518
+ );
519
+ const runAt = normalizeText(input.runAt ?? input.at ?? fallback.runAt ?? fallback.at);
520
+ if (scheduleType === 'once' || scheduleType === 'daily' || scheduleType === 'weekly') {
521
+ if (!runAt) throw new Error(`schedule runAt is required for ${scheduleType} task`);
522
+ const ts = Date.parse(runAt);
523
+ if (!Number.isFinite(ts)) throw new Error('schedule runAt must be valid ISO datetime');
524
+ }
525
+ return {
526
+ scheduleType,
527
+ intervalMinutes,
528
+ runAt: scheduleType === 'once' || scheduleType === 'daily' || scheduleType === 'weekly'
529
+ ? new Date(runAt).toISOString()
530
+ : null,
531
+ };
532
+ }
533
+
534
+ function nextAnchoredRunAt(anchorIso, periodMs, fromTime = Date.now(), afterRun = false) {
535
+ const anchorTs = Date.parse(String(anchorIso || ''));
536
+ if (!Number.isFinite(anchorTs)) return null;
537
+ if (!Number.isFinite(periodMs) || periodMs <= 0) return null;
538
+ if (!afterRun && anchorTs >= fromTime) return new Date(anchorTs).toISOString();
539
+ const delta = Math.max(0, fromTime - anchorTs);
540
+ let steps = Math.floor(delta / periodMs);
541
+ if (afterRun || anchorTs + (steps * periodMs) <= fromTime) steps += 1;
542
+ const nextTs = anchorTs + (steps * periodMs);
543
+ return new Date(nextTs).toISOString();
544
+ }
545
+
546
+ function nextRunAt(task, fromTime = Date.now(), afterRun = false) {
547
+ const enabled = task.enabled !== false;
548
+ if (!enabled) return null;
549
+ const maxRuns = normalizeMaxRuns(task.maxRuns, null);
550
+ const runCount = Number(task.runCount) || 0;
551
+ if (maxRuns && runCount >= maxRuns) return null;
552
+ if (task.scheduleType === 'once') {
553
+ if (afterRun) return null;
554
+ return task.runAt || null;
555
+ }
556
+ if (task.scheduleType === 'daily') {
557
+ return nextAnchoredRunAt(task.runAt, DAY_MS, fromTime, afterRun);
558
+ }
559
+ if (task.scheduleType === 'weekly') {
560
+ return nextAnchoredRunAt(task.runAt, 7 * DAY_MS, fromTime, afterRun);
561
+ }
562
+ const intervalMinutes = normalizePositiveInt(task.intervalMinutes, DEFAULT_INTERVAL_MINUTES);
563
+ return new Date(fromTime + (intervalMinutes * 60 * 1000)).toISOString();
564
+ }
565
+
566
+ function normalizeTaskRecord(raw = {}) {
567
+ const task = {
568
+ id: normalizeText(raw.id),
569
+ seq: Number(raw.seq) || 0,
570
+ name: normalizeText(raw.name),
571
+ enabled: normalizeBoolean(raw.enabled, true),
572
+ scheduleType: normalizeScheduleType(raw.scheduleType ?? raw.type),
573
+ intervalMinutes: normalizePositiveInt(raw.intervalMinutes ?? raw.everyMinutes, DEFAULT_INTERVAL_MINUTES),
574
+ runAt: normalizeText(raw.runAt ?? raw.at),
575
+ maxRuns: normalizeMaxRuns(raw.maxRuns ?? raw.max_runs ?? raw.maxRunsCount, null),
576
+ nextRunAt: normalizeText(raw.nextRunAt),
577
+ commandType: normalizeText(raw.commandType) || DEFAULT_COMMAND_TYPE,
578
+ commandArgv: normalizeCommandArgv(raw.commandArgv),
579
+ createdAt: normalizeText(raw.createdAt),
580
+ updatedAt: normalizeText(raw.updatedAt),
581
+ lastRunAt: normalizeText(raw.lastRunAt),
582
+ lastStatus: normalizeText(raw.lastStatus),
583
+ lastError: normalizeText(raw.lastError),
584
+ lastRunId: normalizeText(raw.lastRunId),
585
+ lastDurationMs: Number(raw.lastDurationMs) || null,
586
+ runCount: Number(raw.runCount) || 0,
587
+ failCount: Number(raw.failCount) || 0,
588
+ };
589
+ if (!task.id) return null;
590
+ if (!task.name) task.name = task.id;
591
+ if (task.scheduleType === 'once' || task.scheduleType === 'daily' || task.scheduleType === 'weekly') {
592
+ if (!task.runAt) return null;
593
+ const ts = Date.parse(task.runAt);
594
+ if (!Number.isFinite(ts)) return null;
595
+ task.runAt = new Date(task.runAt).toISOString();
596
+ } else {
597
+ task.runAt = null;
598
+ }
599
+ if (task.maxRuns && task.runCount >= task.maxRuns) {
600
+ task.enabled = false;
601
+ }
602
+ return task;
603
+ }
604
+
605
+ function ensureTask(id, index) {
606
+ const task = index.tasks.find((item) => item.id === id) || null;
607
+ if (!task) throw new Error(`task not found: ${id}`);
608
+ return task;
609
+ }
610
+
611
+ function sortedTasks(tasks) {
612
+ return [...tasks].sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
613
+ }
614
+
615
+ function sanitizeTaskForOutput(task) {
616
+ return { ...task, commandArgv: { ...(task.commandArgv || {}) } };
617
+ }
618
+
619
+ export function listScheduleTasks() {
620
+ const index = loadIndex();
621
+ const tasks = sortedTasks(index.tasks.map(normalizeTaskRecord).filter(Boolean));
622
+ return {
623
+ root: resolveSchedulesRoot(),
624
+ count: tasks.length,
625
+ tasks: tasks.map(sanitizeTaskForOutput),
626
+ };
627
+ }
628
+
629
+ export function getScheduleTask(id) {
630
+ const index = loadIndex();
631
+ const target = ensureTask(String(id || '').trim(), index);
632
+ const task = normalizeTaskRecord(target);
633
+ if (!task) throw new Error(`invalid task: ${id}`);
634
+ return sanitizeTaskForOutput(task);
635
+ }
636
+
637
+ export function acquireScheduleDaemonLease(options = {}) {
638
+ const ownerId = normalizeText(options.ownerId) || `daemon-${process.pid}`;
639
+ const leaseMs = normalizePositiveInt(options.leaseMs, DEFAULT_DAEMON_LEASE_MS);
640
+ const payload = acquireLease(resolveDaemonLeasePath(), {
641
+ ownerId,
642
+ leaseMs,
643
+ meta: {
644
+ kind: 'schedule-daemon',
645
+ pid: process.pid,
646
+ },
647
+ });
648
+ if (!payload.ok) {
649
+ return { ok: false, reason: payload.reason, lease: payload.lease };
650
+ }
651
+ return { ok: true, ownerId, leaseMs, lease: payload.lease };
652
+ }
653
+
654
+ export function renewScheduleDaemonLease(options = {}) {
655
+ const ownerId = normalizeText(options.ownerId) || `daemon-${process.pid}`;
656
+ const leaseMs = normalizePositiveInt(options.leaseMs, DEFAULT_DAEMON_LEASE_MS);
657
+ return renewLease(resolveDaemonLeasePath(), { ownerId, leaseMs });
658
+ }
659
+
660
+ export function releaseScheduleDaemonLease(options = {}) {
661
+ const ownerId = normalizeText(options.ownerId) || `daemon-${process.pid}`;
662
+ return releaseLease(resolveDaemonLeasePath(), { ownerId });
663
+ }
664
+
665
+ function releaseResourceClaims(resourceKeys, ownerId, runToken = null) {
666
+ const outcomes = [];
667
+ for (const key of resourceKeys) {
668
+ const lockPath = resolveResourceClaimPath(key);
669
+ outcomes.push({
670
+ resourceKey: key,
671
+ ...releaseLease(lockPath, { ownerId, runToken }),
672
+ });
673
+ }
674
+ return outcomes;
675
+ }
676
+
677
+ export function claimScheduleTask(task, options = {}) {
678
+ const normalizedTask = normalizeTaskRecord(task);
679
+ if (!normalizedTask?.id) throw new Error('claim requires task with id');
680
+ const ownerId = normalizeText(options.ownerId) || `runner-${process.pid}`;
681
+ const runToken = normalizeText(options.runToken) || `run-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
682
+ const leaseMs = normalizePositiveInt(options.leaseMs, DEFAULT_TASK_LEASE_MS);
683
+ const policy = normalizeSchedulerPolicy(options.policy || getSchedulerPolicy());
684
+ const mutex = acquireLease(resolveClaimMutexPath(), {
685
+ ownerId,
686
+ runToken,
687
+ leaseMs: Math.min(leaseMs, 5_000),
688
+ meta: {
689
+ kind: 'schedule-claim-mutex',
690
+ pid: process.pid,
691
+ },
692
+ });
693
+ if (!mutex.ok) {
694
+ return { ok: false, claimed: false, reason: 'claim_mutex_busy', lease: mutex.lease };
695
+ }
696
+ try {
697
+ const concurrency = checkConcurrencyAllowance(normalizedTask, policy, Date.now());
698
+ if (!concurrency.ok) {
699
+ return { ok: false, claimed: false, reason: concurrency.reason, details: concurrency };
700
+ }
701
+ const platform = extractTaskPlatform(normalizedTask);
702
+ const resourceKeys = buildResourceKeys(normalizedTask, policy);
703
+ const taskLock = acquireLease(resolveTaskClaimPath(normalizedTask.id), {
704
+ ownerId,
705
+ runToken,
706
+ leaseMs,
707
+ meta: {
708
+ kind: 'schedule-task',
709
+ taskId: normalizedTask.id,
710
+ platform,
711
+ resourceKeys,
712
+ pid: process.pid,
713
+ },
714
+ });
715
+ if (!taskLock.ok) {
716
+ return { ok: false, claimed: false, reason: 'task_busy', lease: taskLock.lease };
717
+ }
718
+ const claimedResourceKeys = [];
719
+ for (const resourceKey of resourceKeys) {
720
+ const resourceLock = acquireLease(resolveResourceClaimPath(resourceKey), {
721
+ ownerId,
722
+ runToken,
723
+ leaseMs,
724
+ meta: {
725
+ kind: 'schedule-resource',
726
+ taskId: normalizedTask.id,
727
+ resourceKey,
728
+ platform,
729
+ pid: process.pid,
730
+ },
731
+ });
732
+ if (!resourceLock.ok) {
733
+ releaseResourceClaims(claimedResourceKeys, ownerId, runToken);
734
+ releaseLease(resolveTaskClaimPath(normalizedTask.id), { ownerId, runToken });
735
+ return {
736
+ ok: false,
737
+ claimed: false,
738
+ reason: 'resource_busy',
739
+ resourceKey,
740
+ lease: resourceLock.lease,
741
+ };
742
+ }
743
+ claimedResourceKeys.push(resourceKey);
744
+ }
745
+ return {
746
+ ok: true,
747
+ claimed: true,
748
+ taskId: normalizedTask.id,
749
+ ownerId,
750
+ runToken,
751
+ leaseMs,
752
+ platform,
753
+ resourceKeys: claimedResourceKeys,
754
+ policy,
755
+ };
756
+ } finally {
757
+ releaseLease(resolveClaimMutexPath(), { ownerId, runToken });
758
+ }
759
+ }
760
+
761
+ export function renewScheduleTaskClaim(taskId, options = {}) {
762
+ const id = String(taskId || '').trim();
763
+ if (!id) throw new Error('task id is required');
764
+ const ownerId = normalizeText(options.ownerId) || `runner-${process.pid}`;
765
+ const runToken = normalizeText(options.runToken) || null;
766
+ const leaseMs = normalizePositiveInt(options.leaseMs, DEFAULT_TASK_LEASE_MS);
767
+ const taskPath = resolveTaskClaimPath(id);
768
+ const head = renewLease(taskPath, { ownerId, runToken, leaseMs });
769
+ if (!head.ok) return head;
770
+ const claim = readLease(taskPath);
771
+ const resourceKeys = Array.isArray(claim?.resourceKeys) ? claim.resourceKeys : [];
772
+ for (const key of resourceKeys) {
773
+ const ret = renewLease(resolveResourceClaimPath(key), { ownerId, runToken, leaseMs });
774
+ if (!ret.ok) {
775
+ return {
776
+ ok: false,
777
+ reason: 'resource_renew_failed',
778
+ resourceKey: key,
779
+ };
780
+ }
781
+ }
782
+ return { ok: true, taskId: id, lease: readLease(taskPath) };
783
+ }
784
+
785
+ export function releaseScheduleTaskClaim(taskId, options = {}) {
786
+ const id = String(taskId || '').trim();
787
+ if (!id) throw new Error('task id is required');
788
+ const ownerId = normalizeText(options.ownerId) || `runner-${process.pid}`;
789
+ const runToken = normalizeText(options.runToken) || null;
790
+ const taskPath = resolveTaskClaimPath(id);
791
+ const claim = readLease(taskPath);
792
+ const resourceKeys = Array.isArray(claim?.resourceKeys) ? claim.resourceKeys : [];
793
+ const resources = releaseResourceClaims(resourceKeys, ownerId, runToken);
794
+ const taskLock = releaseLease(taskPath, { ownerId, runToken });
795
+ return {
796
+ ok: taskLock.ok,
797
+ taskId: id,
798
+ taskLock,
799
+ resources,
800
+ };
801
+ }
802
+
803
+ export function addScheduleTask(input = {}) {
804
+ const index = loadIndex();
805
+ const seq = Number(index.nextSeq) || 1;
806
+ const id = normalizeText(input.id) || `sched-${formatSeq(seq)}`;
807
+ if (index.tasks.some((item) => item.id === id)) {
808
+ throw new Error(`task id already exists: ${id}`);
809
+ }
810
+
811
+ const schedule = normalizeScheduleFields(input);
812
+ const commandType = normalizeText(input.commandType) || DEFAULT_COMMAND_TYPE;
813
+ const commandArgv = normalizeCommandArgv(input.commandArgv);
814
+ const enabled = normalizeBoolean(input.enabled, true);
815
+ const maxRuns = normalizeMaxRuns(input.maxRuns, null);
816
+ const now = nowIso();
817
+ const task = {
818
+ id,
819
+ seq,
820
+ name: normalizeText(input.name) || id,
821
+ enabled,
822
+ scheduleType: schedule.scheduleType,
823
+ intervalMinutes: schedule.intervalMinutes,
824
+ runAt: schedule.runAt,
825
+ maxRuns,
826
+ nextRunAt: nextRunAt({
827
+ enabled,
828
+ scheduleType: schedule.scheduleType,
829
+ intervalMinutes: schedule.intervalMinutes,
830
+ runAt: schedule.runAt,
831
+ maxRuns,
832
+ runCount: 0,
833
+ }),
834
+ commandType,
835
+ commandArgv,
836
+ createdAt: now,
837
+ updatedAt: now,
838
+ lastRunAt: null,
839
+ lastStatus: null,
840
+ lastError: null,
841
+ lastRunId: null,
842
+ lastDurationMs: null,
843
+ runCount: 0,
844
+ failCount: 0,
845
+ };
846
+
847
+ validateCommand(task);
848
+
849
+ index.tasks.push(task);
850
+ index.nextSeq = seq + 1;
851
+ saveIndex(index);
852
+ return sanitizeTaskForOutput(task);
853
+ }
854
+
855
+ export function updateScheduleTask(id, patch = {}) {
856
+ const index = loadIndex();
857
+ const target = ensureTask(String(id || '').trim(), index);
858
+ const before = normalizeTaskRecord(target);
859
+ if (!before) throw new Error(`invalid task: ${id}`);
860
+
861
+ const schedule = normalizeScheduleFields({
862
+ scheduleType: patch.scheduleType ?? patch.type ?? before.scheduleType,
863
+ intervalMinutes: patch.intervalMinutes ?? patch.everyMinutes ?? before.intervalMinutes,
864
+ runAt: patch.runAt ?? patch.at ?? before.runAt,
865
+ });
866
+
867
+ const enabled = patch.enabled === undefined ? before.enabled : normalizeBoolean(patch.enabled, before.enabled);
868
+ const commandType = patch.commandType === undefined ? before.commandType : (normalizeText(patch.commandType) || before.commandType);
869
+ const commandArgv = patch.commandArgv === undefined ? before.commandArgv : normalizeCommandArgv(patch.commandArgv);
870
+ const maxRuns = patch.maxRuns === undefined ? before.maxRuns : normalizeMaxRuns(patch.maxRuns, before.maxRuns);
871
+
872
+ const task = {
873
+ ...before,
874
+ name: patch.name === undefined ? before.name : (normalizeText(patch.name) || before.name),
875
+ enabled,
876
+ scheduleType: schedule.scheduleType,
877
+ intervalMinutes: schedule.intervalMinutes,
878
+ runAt: schedule.runAt,
879
+ maxRuns,
880
+ commandType,
881
+ commandArgv,
882
+ updatedAt: nowIso(),
883
+ };
884
+ task.nextRunAt = nextRunAt(task, Date.now(), false);
885
+ if (task.maxRuns && task.runCount >= task.maxRuns) {
886
+ task.enabled = false;
887
+ task.nextRunAt = null;
888
+ }
889
+
890
+ validateCommand(task);
891
+
892
+ const idx = index.tasks.findIndex((item) => item.id === before.id);
893
+ if (idx < 0) throw new Error(`task not found: ${id}`);
894
+ index.tasks[idx] = task;
895
+ saveIndex(index);
896
+ return sanitizeTaskForOutput(task);
897
+ }
898
+
899
+ export function removeScheduleTask(id) {
900
+ const index = loadIndex();
901
+ const value = String(id || '').trim();
902
+ if (!value) throw new Error('task id is required');
903
+ const idx = index.tasks.findIndex((item) => item.id === value);
904
+ if (idx < 0) throw new Error(`task not found: ${value}`);
905
+ const [removed] = index.tasks.splice(idx, 1);
906
+ saveIndex(index);
907
+ return sanitizeTaskForOutput(removed);
908
+ }
909
+
910
+ export function listDueScheduleTasks(limit = 20, nowMs = Date.now()) {
911
+ const index = loadIndex();
912
+ const tasks = index.tasks
913
+ .map(normalizeTaskRecord)
914
+ .filter(Boolean)
915
+ .filter((task) => task.enabled === true && normalizeText(task.nextRunAt))
916
+ .filter((task) => {
917
+ const ts = Date.parse(String(task.nextRunAt || ''));
918
+ return Number.isFinite(ts) && ts <= nowMs;
919
+ })
920
+ .sort((a, b) => Date.parse(String(a.nextRunAt || '')) - Date.parse(String(b.nextRunAt || '')));
921
+ const sliced = Number.isFinite(Number(limit)) ? tasks.slice(0, Math.max(1, Number(limit))) : tasks;
922
+ return sliced.map(sanitizeTaskForOutput);
923
+ }
924
+
925
+ export function markScheduleTaskResult(id, result = {}) {
926
+ const index = loadIndex();
927
+ const target = ensureTask(String(id || '').trim(), index);
928
+ const task = normalizeTaskRecord(target);
929
+ if (!task) throw new Error(`invalid task: ${id}`);
930
+
931
+ const status = String(result.status || 'failed').trim().toLowerCase() === 'success' ? 'success' : 'failed';
932
+ const finishedAt = normalizeText(result.finishedAt) || nowIso();
933
+ task.lastRunAt = finishedAt;
934
+ task.lastStatus = status;
935
+ task.lastError = status === 'failed' ? (normalizeText(result.error) || 'unknown_error') : null;
936
+ task.lastRunId = normalizeText(result.runId);
937
+ task.lastDurationMs = Number.isFinite(Number(result.durationMs)) ? Number(result.durationMs) : null;
938
+ task.runCount = (Number(task.runCount) || 0) + 1;
939
+ if (status !== 'success') task.failCount = (Number(task.failCount) || 0) + 1;
940
+
941
+ const fromMs = Number.isFinite(Date.parse(finishedAt)) ? Date.parse(finishedAt) : Date.now();
942
+ task.nextRunAt = nextRunAt(task, fromMs, true);
943
+ if (task.scheduleType === 'once') {
944
+ task.enabled = false;
945
+ }
946
+ if (task.maxRuns && task.runCount >= task.maxRuns) {
947
+ task.enabled = false;
948
+ task.nextRunAt = null;
949
+ }
950
+ task.updatedAt = nowIso();
951
+
952
+ const idx = index.tasks.findIndex((item) => item.id === task.id);
953
+ if (idx < 0) throw new Error(`task not found: ${id}`);
954
+ index.tasks[idx] = task;
955
+ saveIndex(index);
956
+ return sanitizeTaskForOutput(task);
957
+ }
958
+
959
+ function normalizeImportedTask(raw = {}, fallbackSeq = 1) {
960
+ const schedule = normalizeScheduleFields(raw, raw);
961
+ const now = nowIso();
962
+ const task = {
963
+ id: normalizeText(raw.id) || null,
964
+ seq: Number(raw.seq) || fallbackSeq,
965
+ name: normalizeText(raw.name) || null,
966
+ enabled: normalizeBoolean(raw.enabled, true),
967
+ scheduleType: schedule.scheduleType,
968
+ intervalMinutes: schedule.intervalMinutes,
969
+ runAt: schedule.runAt,
970
+ maxRuns: normalizeMaxRuns(raw.maxRuns ?? raw.max_runs ?? raw.maxRunsCount, null),
971
+ nextRunAt: normalizeText(raw.nextRunAt),
972
+ commandType: normalizeText(raw.commandType) || DEFAULT_COMMAND_TYPE,
973
+ commandArgv: normalizeCommandArgv(raw.commandArgv ?? raw.argv),
974
+ createdAt: normalizeText(raw.createdAt) || now,
975
+ updatedAt: now,
976
+ lastRunAt: normalizeText(raw.lastRunAt),
977
+ lastStatus: normalizeText(raw.lastStatus),
978
+ lastError: normalizeText(raw.lastError),
979
+ lastRunId: normalizeText(raw.lastRunId),
980
+ lastDurationMs: Number.isFinite(Number(raw.lastDurationMs)) ? Number(raw.lastDurationMs) : null,
981
+ runCount: Number(raw.runCount) || 0,
982
+ failCount: Number(raw.failCount) || 0,
983
+ };
984
+ task.name = task.name || task.id || `sched-${formatSeq(task.seq)}`;
985
+ if (!task.nextRunAt) task.nextRunAt = nextRunAt(task);
986
+ validateCommand(task);
987
+ return task;
988
+ }
989
+
990
+ export function importScheduleTasks(payload, options = {}) {
991
+ const mode = String(options.mode || 'merge').trim().toLowerCase();
992
+ const replace = mode === 'replace';
993
+ const incomingRaw = Array.isArray(payload?.tasks)
994
+ ? payload.tasks
995
+ : Array.isArray(payload)
996
+ ? payload
997
+ : (payload && typeof payload === 'object')
998
+ ? [payload]
999
+ : [];
1000
+ if (incomingRaw.length === 0) throw new Error('import payload has no tasks');
1001
+
1002
+ const index = loadIndex();
1003
+ if (replace) index.tasks = [];
1004
+
1005
+ const upserted = [];
1006
+ for (let i = 0; i < incomingRaw.length; i += 1) {
1007
+ const normalized = normalizeImportedTask(incomingRaw[i], (Number(index.nextSeq) || 1) + i);
1008
+ const existingIdx = normalized.id
1009
+ ? index.tasks.findIndex((item) => item.id === normalized.id)
1010
+ : -1;
1011
+ if (existingIdx >= 0) {
1012
+ const existing = normalizeTaskRecord(index.tasks[existingIdx]);
1013
+ if (!existing) continue;
1014
+ const merged = {
1015
+ ...existing,
1016
+ ...normalized,
1017
+ id: existing.id,
1018
+ seq: existing.seq,
1019
+ createdAt: existing.createdAt,
1020
+ updatedAt: nowIso(),
1021
+ };
1022
+ index.tasks[existingIdx] = merged;
1023
+ upserted.push(sanitizeTaskForOutput(merged));
1024
+ continue;
1025
+ }
1026
+ const seq = Number(index.nextSeq) || 1;
1027
+ const id = normalized.id || `sched-${formatSeq(seq)}`;
1028
+ const task = {
1029
+ ...normalized,
1030
+ id,
1031
+ seq,
1032
+ name: normalized.name || id,
1033
+ createdAt: normalized.createdAt || nowIso(),
1034
+ updatedAt: nowIso(),
1035
+ };
1036
+ if (!task.nextRunAt) task.nextRunAt = nextRunAt(task);
1037
+ index.tasks.push(task);
1038
+ index.nextSeq = seq + 1;
1039
+ upserted.push(sanitizeTaskForOutput(task));
1040
+ }
1041
+
1042
+ saveIndex(index);
1043
+ return {
1044
+ mode: replace ? 'replace' : 'merge',
1045
+ count: upserted.length,
1046
+ tasks: upserted,
1047
+ };
1048
+ }
1049
+
1050
+ export function exportScheduleTasks(id = null) {
1051
+ const list = listScheduleTasks();
1052
+ if (id) {
1053
+ const task = list.tasks.find((item) => item.id === String(id || '').trim());
1054
+ if (!task) throw new Error(`task not found: ${id}`);
1055
+ return {
1056
+ version: 1,
1057
+ exportedAt: nowIso(),
1058
+ count: 1,
1059
+ tasks: [task],
1060
+ };
1061
+ }
1062
+ return {
1063
+ version: 1,
1064
+ exportedAt: nowIso(),
1065
+ count: list.tasks.length,
1066
+ tasks: list.tasks,
1067
+ };
1068
+ }