target-supabase-sdk 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -18
- package/dist/browser.d.ts +11 -4
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +3 -3
- package/dist/chunks/{enable-log-persist-C7GJo_Nc.js → enable-log-persist-BL2FaGQp.js} +10 -8
- package/dist/chunks/enable-log-persist-BL2FaGQp.js.map +1 -0
- package/dist/chunks/{create-logger-DyH3mZ8p.js → list.api-Ab9v8yOg.js} +66 -57
- package/dist/chunks/list.api-Ab9v8yOg.js.map +1 -0
- package/dist/chunks/{word.interface-DLgRobKV.js → word.interface-Mw7bTSqs.js} +1191 -97
- package/dist/chunks/word.interface-Mw7bTSqs.js.map +1 -0
- package/dist/extraction/extraction.api.d.ts +117 -0
- package/dist/extraction/extraction.api.d.ts.map +1 -0
- package/dist/extraction/extraction.interface.d.ts +43 -0
- package/dist/extraction/extraction.interface.d.ts.map +1 -0
- package/dist/list/list.interface.d.ts +2 -2
- package/dist/list/list.interface.d.ts.map +1 -1
- package/dist/node/fs/json-state-store.d.ts.map +1 -1
- package/dist/node/fs/runtime-state-path.d.ts +17 -0
- package/dist/node/fs/runtime-state-path.d.ts.map +1 -0
- package/dist/node/fs/sharded-json-state-store.d.ts +19 -0
- package/dist/node/fs/sharded-json-state-store.d.ts.map +1 -0
- package/dist/node/node-runtime.base.d.ts +13 -1
- package/dist/node/node-runtime.base.d.ts.map +1 -1
- package/dist/node/process/create-managed-child-processes.d.ts +12 -0
- package/dist/node/process/create-managed-child-processes.d.ts.map +1 -0
- package/dist/node/process/index.d.ts +3 -1
- package/dist/node/process/index.d.ts.map +1 -1
- package/dist/node/process/managed-child-processes.d.ts +15 -2
- package/dist/node/process/managed-child-processes.d.ts.map +1 -1
- package/dist/node/runtime-state/create-service-runtime-state-store.d.ts +27 -0
- package/dist/node/runtime-state/create-service-runtime-state-store.d.ts.map +1 -0
- package/dist/node/runtime-state/index.d.ts +4 -0
- package/dist/node/runtime-state/index.d.ts.map +1 -0
- package/dist/node/runtime-state/service-runtime-state.types.d.ts +57 -0
- package/dist/node/runtime-state/service-runtime-state.types.d.ts.map +1 -0
- package/dist/node/service-guard/index.d.ts +9 -0
- package/dist/node/service-guard/index.d.ts.map +1 -0
- package/dist/node/service-guard/register-service-guard-runner.d.ts +9 -0
- package/dist/node/service-guard/register-service-guard-runner.d.ts.map +1 -0
- package/dist/node/service-guard/run-readiness-gate.d.ts +11 -0
- package/dist/node/service-guard/run-readiness-gate.d.ts.map +1 -0
- package/dist/node/service-guard/run-service-guard-tick.d.ts +7 -0
- package/dist/node/service-guard/run-service-guard-tick.d.ts.map +1 -0
- package/dist/node/service-guard/service-guard-node.d.ts +18 -0
- package/dist/node/service-guard/service-guard-node.d.ts.map +1 -0
- package/dist/node/service-guard/service-guard.interface.d.ts +58 -0
- package/dist/node/service-guard/service-guard.interface.d.ts.map +1 -0
- package/dist/node/service-guard/worker-spawn-cooldown.d.ts +4 -0
- package/dist/node/service-guard/worker-spawn-cooldown.d.ts.map +1 -0
- package/dist/node/service-host/index.d.ts +9 -0
- package/dist/node/service-host/index.d.ts.map +1 -0
- package/dist/node/service-host/registry-slot-guard-step.d.ts +21 -0
- package/dist/node/service-host/registry-slot-guard-step.d.ts.map +1 -0
- package/dist/node/service-host/service-host.d.ts +36 -0
- package/dist/node/service-host/service-host.d.ts.map +1 -0
- package/dist/node/service-host/single-process-service.d.ts +21 -0
- package/dist/node/service-host/single-process-service.d.ts.map +1 -0
- package/dist/node.d.ts +15 -2
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2221 -1485
- package/dist/node.js.map +1 -1
- package/dist/preload.js +3 -3
- package/dist/service/config.api.d.ts +143 -0
- package/dist/service/config.api.d.ts.map +1 -0
- package/dist/service/config.interface.d.ts +22 -0
- package/dist/service/config.interface.d.ts.map +1 -0
- package/dist/service/index.d.ts +16 -17
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/registry-lifecycle.d.ts +43 -0
- package/dist/service/registry-lifecycle.d.ts.map +1 -0
- package/dist/service/registry.service.d.ts +117 -0
- package/dist/service/registry.service.d.ts.map +1 -0
- package/dist/service/service-bootstrap.d.ts +28 -0
- package/dist/service/service-bootstrap.d.ts.map +1 -0
- package/dist/service/service.api.d.ts +160 -0
- package/dist/service/service.api.d.ts.map +1 -1
- package/dist/service/service.interface.d.ts +32 -1
- package/dist/service/service.interface.d.ts.map +1 -1
- package/dist/shared/log/index.d.ts +4 -0
- package/dist/shared/log/index.d.ts.map +1 -1
- package/dist/shared/log/log-batch.d.ts +44 -0
- package/dist/shared/log/log-batch.d.ts.map +1 -0
- package/dist/shared/log/log-persist-coordinator.d.ts +37 -0
- package/dist/shared/log/log-persist-coordinator.d.ts.map +1 -0
- package/dist/shared/log/log-persist-flush.d.ts.map +1 -1
- package/dist/shared/log/log-persist.config.d.ts +0 -1
- package/dist/shared/log/log-persist.config.d.ts.map +1 -1
- package/dist/shared/log/log-persist.interface.d.ts +8 -0
- package/dist/shared/log/log-persist.interface.d.ts.map +1 -1
- package/dist/shared/utils/config-path.utils.d.ts +21 -0
- package/dist/shared/utils/config-path.utils.d.ts.map +1 -1
- package/dist/shared/utils/get-value-at-path.d.ts +3 -0
- package/dist/shared/utils/get-value-at-path.d.ts.map +1 -0
- package/dist/task/index.d.ts +2 -0
- package/dist/task/index.d.ts.map +1 -1
- package/dist/task/task-manager.d.ts +1 -1
- package/dist/task/task-manager.d.ts.map +1 -1
- package/dist/task/task-post-retry.api.d.ts +24 -0
- package/dist/task/task-post-retry.api.d.ts.map +1 -0
- package/dist/task/task-repo-context.d.ts +7 -1
- package/dist/task/task-repo-context.d.ts.map +1 -1
- package/dist/trigger/trigger-manager.d.ts +1 -1
- package/dist/trigger/trigger-manager.d.ts.map +1 -1
- package/dist/trigger/trigger-node.d.ts +1 -0
- package/dist/trigger/trigger-node.d.ts.map +1 -1
- package/dist/trigger/trigger.constant.d.ts +1 -1
- package/dist/trigger/trigger.constant.d.ts.map +1 -1
- package/dist/trigger/trigger.interface.d.ts +10 -0
- package/dist/trigger/trigger.interface.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/browser.ts +102 -4
- package/src/extraction/extraction.api.ts +232 -0
- package/src/extraction/extraction.interface.ts +45 -0
- package/src/list/list.interface.ts +2 -2
- package/src/node/fs/json-state-store.ts +5 -2
- package/src/node/fs/runtime-state-path.ts +35 -0
- package/src/node/fs/sharded-json-state-store.ts +157 -0
- package/src/node/node-runtime.base.ts +37 -6
- package/src/node/process/create-managed-child-processes.ts +27 -0
- package/src/node/process/index.ts +4 -0
- package/src/node/process/managed-child-processes.ts +48 -15
- package/src/node/runtime-state/create-service-runtime-state-store.ts +125 -0
- package/src/node/runtime-state/index.ts +17 -0
- package/src/node/runtime-state/service-runtime-state.types.ts +64 -0
- package/src/node/service-guard/index.ts +17 -0
- package/src/node/service-guard/register-service-guard-runner.ts +64 -0
- package/src/node/service-guard/run-readiness-gate.ts +34 -0
- package/src/node/service-guard/run-service-guard-tick.ts +106 -0
- package/src/node/service-guard/service-guard-node.ts +52 -0
- package/src/node/service-guard/service-guard.interface.ts +64 -0
- package/src/node/service-guard/worker-spawn-cooldown.ts +13 -0
- package/src/node/service-host/index.ts +30 -0
- package/src/node/service-host/registry-slot-guard-step.ts +57 -0
- package/src/node/service-host/service-host.ts +168 -0
- package/src/node/service-host/single-process-service.ts +58 -0
- package/src/node.ts +69 -0
- package/src/service/config.api.ts +224 -0
- package/src/service/config.interface.ts +25 -0
- package/src/service/index.ts +78 -149
- package/src/service/registry-lifecycle.ts +144 -0
- package/src/service/registry.service.ts +726 -0
- package/src/service/service-bootstrap.ts +74 -0
- package/src/service/service.api.ts +13 -0
- package/src/service/service.interface.ts +36 -1
- package/src/shared/log/index.ts +13 -0
- package/src/shared/log/log-batch.ts +156 -0
- package/src/shared/log/log-persist-coordinator.ts +107 -0
- package/src/shared/log/log-persist-flush.ts +3 -3
- package/src/shared/log/log-persist.config.ts +0 -2
- package/src/shared/log/log-persist.interface.ts +10 -0
- package/src/shared/utils/config-path.utils.ts +35 -0
- package/src/shared/utils/get-value-at-path.ts +17 -0
- package/src/task/index.ts +2 -0
- package/src/task/task-manager.ts +10 -4
- package/src/task/task-post-retry.api.ts +61 -0
- package/src/task/task-repo-context.ts +8 -1
- package/src/trigger/trigger-manager.ts +4 -2
- package/src/trigger/trigger-node.ts +10 -4
- package/src/trigger/trigger.constant.ts +1 -1
- package/src/trigger/trigger.interface.ts +10 -0
- package/dist/chunks/create-logger-DyH3mZ8p.js.map +0 -1
- package/dist/chunks/enable-log-persist-C7GJo_Nc.js.map +0 -1
- package/dist/chunks/word.interface-DLgRobKV.js.map +0 -1
package/dist/node.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { M as MAX_POLL_TARGET_LIST_SIZE,
|
|
3
|
-
export { B as BaseValidator, C as CREATE_TARGET_ALREADY_EXISTS_MESSAGE, a as CategoryList, b as MAX_TARGET_LIST_PAGE_SIZE, O as OPTIMISTIC_LOCK_ERROR_CODE,
|
|
4
|
-
import { l as loadEnvFiles, b as readEnv, c as requireEnv } from './chunks/enable-log-persist-
|
|
5
|
-
export { C as CLI_PROD_FLAG, S as SERVICE_ENV_PROFILE_ENV_KEY,
|
|
6
|
-
import { access, stat, readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
|
|
7
|
-
import { resolve, join,
|
|
8
|
-
import {
|
|
9
|
-
export { A as ApiMethod, C as
|
|
2
|
+
import { M as MAX_POLL_TARGET_LIST_SIZE, G as validateWithSchema, f as createLogger, w as pollTargetList, i as generateResponse, g as createTarget, D as supabase, u as logManager, I as createScope, c as LogLevel, J as patchScope, A as scanTargetList, K as withModule, j as getPossibleTarget, r as isOptimisticLockResponse, E as updateTargetDetails } from './chunks/list.api-Ab9v8yOg.js';
|
|
3
|
+
export { B as BaseValidator, C as CREATE_TARGET_ALREADY_EXISTS_MESSAGE, a as CategoryList, L as LOG_BATCH_LOADER_KEY, b as LOG_PERSIST_LOADER_KEY, d as MAX_TARGET_LIST_PAGE_SIZE, O as OPTIMISTIC_LOCK_ERROR_CODE, e as OPTIMISTIC_LOCK_FAILED_MESSAGE, P as PostListValidator, S as StatusCode, h as deleteTarget, k as getTarget, l as getTargetList, m as getTargetTotalCount, n as handleSupabaseError, o as isCreateTargetAlreadyExistsError, p as isDevEnvironment, q as isOptimisticLockError, s as logDev, t as logDevGroup, v as patchTarget, x as postListCreate, y as postLogListCreate, z as postTarget, F as validateWith } from './chunks/list.api-Ab9v8yOg.js';
|
|
4
|
+
import { l as loadEnvFiles, b as readEnv, c as requireEnv, d as createJsonFileStateStore, a as logPersistEnabledFromEnv, e as disableLogPersist, g as getLogPersistStats, s as snapshotLogPersistReady, w as waitForLogPersistReady, f as ensureLogPersistFromEnv } from './chunks/enable-log-persist-BL2FaGQp.js';
|
|
5
|
+
export { C as CLI_PROD_FLAG, S as SERVICE_ENV_PROFILE_ENV_KEY, h as SERVICE_ENV_PROFILE_PROD, i as enableLogPersist, j as envBool, k as envInt, m as envMs, n as envNumber, o as envPort, q as envProfileFromProcess, t as parseEnvFile, u as parseEnvLine, p as pinEnvProfileFromArgv, x as pollUntil, y as resolveDefaultEnvFiles, z as resolveLogPersistRegistryPath, r as resolveProjectRootByPackageName, A as resolveProjectRootFromModule, v as validateLogPersistPreloadEnv } from './chunks/enable-log-persist-BL2FaGQp.js';
|
|
6
|
+
import { access, stat, readFile, mkdir, unlink, writeFile, readdir } from 'node:fs/promises';
|
|
7
|
+
import { resolve, join, dirname, isAbsolute } from 'node:path';
|
|
8
|
+
import { bd as sha256Hex, bi as getErrorMessage, aI as postRegisterNode, am as patchChangeNodeStatus, N as NodeStatus, ax as patchStopNode, au as patchNodeHeartBeat, E as EMPTY_REGISTRY_SLOT_RUNTIME_STATE, b3 as runRegistrySlotGuardCheck, aW as registrySlotRuntimePatchFromGuardResult, d as CategoryNode, L as evaluateBusyNodeLiveness, aw as patchServiceRuntime, l as ServiceRegistryError, v as claimServiceRegistrySlot, f as CategoryRepo, R as ResultCode, bj as LOG_TOPIC_TASK, n as TASK_REPO_USAGE, a4 as getScanRemoteRepoValues, aq as patchClaimTask, bk as CRITICAL_UNKNOWN_TASK_TRACE_ID, ao as patchChangeTaskStatus, p as TaskStatusAction, bl as toError, aQ as postTaskSchema, bm as createTaskRow, o as TaskStatus } from './chunks/word.interface-Mw7bTSqs.js';
|
|
9
|
+
export { A as ApiMethod, C as CategoryConfig, a as CategoryExtraction, b as CategoryIdea, c as CategoryLink, e as CategoryParcel, g as CategoryService, h as CategoryTask, i as CategoryWord, D as DEFAULT_SYSTEM_REGISTRY_SEED_SLOTS, M as Market, P as POST_TASK_DEFAULT_MAX_ATTEMPTS, j as ParcelManager, k as PostLinkValidator, S as ServiceLifecycleStatus, m as ServiceSlotStatus, T as TARGET_SYSTEM_REGISTRY_KEY, q as apiDetailsSchema, r as assertRegistrySlotAvailable, s as assertRegistrySlotOwner, t as buildEmptyServiceSlots, u as buildSystemRegistryConfigDetails, w as classifyNetworkError, x as countTasksByType, y as createActiveServiceLifecycle, z as createClaimedRegistrySlotRuntimeState, B as createStorageProviderRegistry, F as createUploadTracker, G as decodeLogBatch, H as decodeLogBatchList, I as defaultL3ServiceDetails, J as deleteParcel, K as deleteParcelSchema, O as fetchBinaryWithRetry, Q as fetchWithRetry, U as fieldDefinitionSchema, V as flattenLogBatch, W as flattenLogBatches, X as formatNetworkError, Y as getApi, Z as getApiSchema, _ as getConfig, $ as getConfigSchema, a0 as getExtraction, a1 as getExtractionSchema, a2 as getParcel, a3 as getParcelSchema, a5 as getScanRemoteRepoValuesSchema, a6 as getService, a7 as getServiceSchema, a8 as getTargetSystemRegistry, a9 as getValueAtPath, aa as installChunkFetchRegistry, ab as isHttpUrl, ac as isLocalFilesystemPath, ad as isLogBatchList, ae as isLogEntry, af as isOpaqueChunkUrl, ag as isRetryableHttpStatus, ah as parcelDetailsSchema, ai as parseLogBatchMeta, aj as parseProviderPrefixedUrl, ak as parseServiceSlot, al as parseServiceSlots, an as patchChangeNodeStatusSchema, ap as patchChangeTaskStatusSchema, ar as patchClaimTaskSchema, as as patchExtractionObjects, at as patchExtractionObjectsSchema, av as patchNodeHeartBeatSchema, ay as patchStopNodeSchema, az as patchTaskProgress, aA as patchTaskProgressSchema, aB as postApi, aC as postApiSchema, aD as postExtraction, aE as postExtractionSchema, aF as postLinkCreate, aG as postParcel, aH as postParcelSchema, aJ as postRegisterNodeSchema, aK as postService, aL as postServiceInstance, aM as postServiceSchema, aN as postSystemRegistryConfig, aO as postSystemRegistryConfigSchema, aP as postTask, aR as postTaskWithRetry, aS as publishParcel, aT as registerProviderChunkResolver, aU as registerService, aV as registerServiceAtStartup, aX as releaseSystemRegistrySlots, aY as requestHasBody, aZ as resetSystemRegistryConfig, a_ as resetSystemRegistryConfigSchema, a$ as resolveActiveRegistryServiceId, b0 as resolveFetchUrl, b1 as restoreParcel, b2 as restoreParcelById, b4 as sanitizeFileName, b5 as sanitizeParcelFileName, b6 as scanExtractionList, b7 as scanExtractionListSchema, b8 as schemaDefinitionSchema, b9 as serviceDetailsSchema, ba as serviceLifecycleSchema, bb as serviceNodeSnapshotSchema, bc as serviceRuntimeSchema, be as summarizeTaskQueue, bf as systemRegistrySeedSlotSchema, bg as unregisterService, bh as unregisterServiceAtShutdown } from './chunks/word.interface-Mw7bTSqs.js';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
|
-
import '@supabase/supabase-js';
|
|
15
14
|
import 'lodash-es';
|
|
15
|
+
import '@supabase/supabase-js';
|
|
16
16
|
import 'node:fs';
|
|
17
17
|
|
|
18
18
|
var CategoryCommand;
|
|
@@ -208,6 +208,7 @@ class BaseNodeRuntime {
|
|
|
208
208
|
/** Log `nodeId` label while startup has not yet received an assigned id from Supabase. */
|
|
209
209
|
static NODE_ID_PENDING_ASSIGNMENT = "pending-node-id";
|
|
210
210
|
runtimeModule;
|
|
211
|
+
beforeProcessExitHook;
|
|
211
212
|
_localNodeId = null;
|
|
212
213
|
_isNodeIdLocked = false;
|
|
213
214
|
isRunning;
|
|
@@ -230,13 +231,32 @@ class BaseNodeRuntime {
|
|
|
230
231
|
this._localNodeId = value;
|
|
231
232
|
this._isNodeIdLocked = true;
|
|
232
233
|
}
|
|
233
|
-
constructor(runtimeModule) {
|
|
234
|
+
constructor(runtimeModule, options) {
|
|
234
235
|
this.runtimeModule = runtimeModule;
|
|
236
|
+
this.beforeProcessExitHook = options?.beforeProcessExit ?? null;
|
|
235
237
|
this.isRunning = false;
|
|
236
238
|
this.loopCount = 0;
|
|
237
239
|
this.startupTraceId = logManager.generateTraceId();
|
|
238
240
|
this.startupScope = createScope({ module: runtimeModule, traceId: this.startupTraceId });
|
|
239
241
|
}
|
|
242
|
+
async runBeforeProcessExit(logger) {
|
|
243
|
+
if (this.beforeProcessExitHook == null) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
await this.beforeProcessExitHook();
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
logger.warn("beforeProcessExit 钩子失败(best-effort)", {
|
|
251
|
+
topic: LOG_TOPIC_PROCESS,
|
|
252
|
+
data: { error: getErrorMessage(error) },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async exitProcess(code, logger) {
|
|
257
|
+
await this.runBeforeProcessExit(logger);
|
|
258
|
+
process.exit(code);
|
|
259
|
+
}
|
|
240
260
|
/** Hook: minimum log level for main-loop scoped loggers (heartbeat, batchCommand, iteration). */
|
|
241
261
|
loopLoggerMinLevel() {
|
|
242
262
|
return LogLevel.INFO;
|
|
@@ -443,7 +463,7 @@ class BaseNodeRuntime {
|
|
|
443
463
|
return this.shutdownPromise;
|
|
444
464
|
}
|
|
445
465
|
this.isRunning = false;
|
|
446
|
-
this.shutdownPromise = this.runShutdown(options?.trigger).catch((error) => {
|
|
466
|
+
this.shutdownPromise = this.runShutdown(options?.trigger).catch(async (error) => {
|
|
447
467
|
const logger = createLogger({
|
|
448
468
|
scope: patchScope({ scope: this.startupScope, patch: { labels: { nodeId: this.getLogNodeId() } } }),
|
|
449
469
|
});
|
|
@@ -454,7 +474,7 @@ class BaseNodeRuntime {
|
|
|
454
474
|
error: getErrorMessage(error),
|
|
455
475
|
},
|
|
456
476
|
});
|
|
457
|
-
|
|
477
|
+
await this.exitProcess(1, logger);
|
|
458
478
|
});
|
|
459
479
|
return this.shutdownPromise;
|
|
460
480
|
}
|
|
@@ -468,7 +488,7 @@ class BaseNodeRuntime {
|
|
|
468
488
|
});
|
|
469
489
|
if (this.localNodeId == null) {
|
|
470
490
|
logger.warn("本地節點未註冊,進程退出", { topic: LOG_TOPIC_NODE });
|
|
471
|
-
|
|
491
|
+
await this.exitProcess(0, logger);
|
|
472
492
|
return;
|
|
473
493
|
}
|
|
474
494
|
logger.debug("正在登出節點", { topic: LOG_TOPIC_NODE, data: { nodeId: this.localNodeId } });
|
|
@@ -489,7 +509,7 @@ class BaseNodeRuntime {
|
|
|
489
509
|
});
|
|
490
510
|
}
|
|
491
511
|
finally {
|
|
492
|
-
|
|
512
|
+
await this.exitProcess(0, logger);
|
|
493
513
|
}
|
|
494
514
|
}
|
|
495
515
|
async heartbeat(ctx) {
|
|
@@ -535,7 +555,7 @@ class BaseNodeRuntime {
|
|
|
535
555
|
return false;
|
|
536
556
|
}
|
|
537
557
|
this.consecutiveHeartbeatFailures = 0;
|
|
538
|
-
logger.
|
|
558
|
+
logger.info("心跳更新成功", {
|
|
539
559
|
topic: LOG_TOPIC_NODE,
|
|
540
560
|
data: { lastHeartBeat: formatHeartbeat(lastHeartBeat) },
|
|
541
561
|
});
|
|
@@ -660,14 +680,21 @@ class ManagedChildProcesses {
|
|
|
660
680
|
projectRoot;
|
|
661
681
|
preloadModules;
|
|
662
682
|
graceMs;
|
|
683
|
+
defaultUseTsx;
|
|
663
684
|
logger;
|
|
664
685
|
byLabel = new Map();
|
|
686
|
+
criticalExitHandler = null;
|
|
687
|
+
suppressCriticalExit = false;
|
|
665
688
|
constructor(options) {
|
|
666
689
|
this.projectRoot = options.projectRoot;
|
|
667
690
|
this.preloadModules = options.preloadModules ?? [];
|
|
668
691
|
this.graceMs = options.graceMs ?? 5_000;
|
|
692
|
+
this.defaultUseTsx = options.useTsx ?? true;
|
|
669
693
|
this.logger = options.logger;
|
|
670
694
|
}
|
|
695
|
+
setCriticalExitHandler(handler) {
|
|
696
|
+
this.criticalExitHandler = handler;
|
|
697
|
+
}
|
|
671
698
|
get(label) {
|
|
672
699
|
return this.byLabel.get(label) ?? null;
|
|
673
700
|
}
|
|
@@ -680,7 +707,7 @@ class ManagedChildProcesses {
|
|
|
680
707
|
.filter((entry) => isChildProcessRunning(entry[1]))
|
|
681
708
|
.map(([label]) => label);
|
|
682
709
|
}
|
|
683
|
-
spawn(label, entryScript) {
|
|
710
|
+
spawn(label, entryScript, spawnOptions) {
|
|
684
711
|
const existing = this.getRunning(label);
|
|
685
712
|
if (existing != null) {
|
|
686
713
|
return { child: existing, created: false };
|
|
@@ -689,6 +716,8 @@ class ManagedChildProcesses {
|
|
|
689
716
|
projectRoot: this.projectRoot,
|
|
690
717
|
entryScript,
|
|
691
718
|
preloadModules: this.preloadModules,
|
|
719
|
+
useTsx: spawnOptions?.useTsx ?? this.defaultUseTsx,
|
|
720
|
+
env: spawnOptions?.env != null ? { ...process.env, ...spawnOptions.env } : process.env,
|
|
692
721
|
});
|
|
693
722
|
child.on("exit", (code, signal) => {
|
|
694
723
|
this.logger?.info("child exited", { topic: "process", data: { label, code, signal } });
|
|
@@ -696,6 +725,11 @@ class ManagedChildProcesses {
|
|
|
696
725
|
if (current === child) {
|
|
697
726
|
this.byLabel.delete(label);
|
|
698
727
|
}
|
|
728
|
+
if (!this.suppressCriticalExit &&
|
|
729
|
+
this.criticalExitHandler != null &&
|
|
730
|
+
this.criticalExitHandler.labels.includes(label)) {
|
|
731
|
+
this.criticalExitHandler.onCriticalExit(label, code, signal);
|
|
732
|
+
}
|
|
699
733
|
});
|
|
700
734
|
this.byLabel.set(label, child);
|
|
701
735
|
this.logger?.info("child started", {
|
|
@@ -709,20 +743,26 @@ class ManagedChildProcesses {
|
|
|
709
743
|
return { child, created: true };
|
|
710
744
|
}
|
|
711
745
|
async stopAll(options) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
746
|
+
this.suppressCriticalExit = true;
|
|
747
|
+
try {
|
|
748
|
+
const tracked = [...this.byLabel.values()].filter((child) => isChildProcessRunning(child));
|
|
749
|
+
await Promise.all(tracked.map((child) => this.stopChild(child)));
|
|
750
|
+
const extraPids = options?.extraPids ?? [];
|
|
751
|
+
for (const pid of extraPids) {
|
|
752
|
+
if (pid == null || tracked.some((child) => child.pid === pid)) {
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
process.kill(pid, "SIGTERM");
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
// already exited
|
|
760
|
+
}
|
|
724
761
|
}
|
|
725
762
|
}
|
|
763
|
+
finally {
|
|
764
|
+
this.suppressCriticalExit = false;
|
|
765
|
+
}
|
|
726
766
|
}
|
|
727
767
|
stopChild(child) {
|
|
728
768
|
return new Promise((resolvePromise) => {
|
|
@@ -737,6 +777,21 @@ class ManagedChildProcesses {
|
|
|
737
777
|
}
|
|
738
778
|
}
|
|
739
779
|
|
|
780
|
+
const DEFAULT_PRELOAD_MODULES = ["./scripts/preload.mjs"];
|
|
781
|
+
/**
|
|
782
|
+
* Standard L3 service child-process registry: node (not tsx), service preload, launcher logger.
|
|
783
|
+
* Each service keeps a singleton in `src/startup/child-processes.ts`.
|
|
784
|
+
*/
|
|
785
|
+
function createManagedChildProcesses(options) {
|
|
786
|
+
return new ManagedChildProcesses({
|
|
787
|
+
projectRoot: options.projectRoot,
|
|
788
|
+
preloadModules: options.preloadModules ?? DEFAULT_PRELOAD_MODULES,
|
|
789
|
+
useTsx: options.useTsx ?? false,
|
|
790
|
+
graceMs: options.graceMs,
|
|
791
|
+
logger: options.logger ?? createLogger({ module: "launcher" }),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
740
795
|
/** Factory: all listed env keys must be non-empty. */
|
|
741
796
|
function createRequiredEnvCheck(name, keys, options) {
|
|
742
797
|
return () => {
|
|
@@ -837,1647 +892,2328 @@ async function waitForServiceReady(gate, options) {
|
|
|
837
892
|
throw new Error(message);
|
|
838
893
|
}
|
|
839
894
|
|
|
840
|
-
|
|
841
|
-
const TASK_REPO_SCRIPT_CATEGORY = "script";
|
|
842
|
-
/** Host app config directory (relative to `cwd`) — recommended for library consumers */
|
|
843
|
-
const TASK_RUNNER_CONFIG_DIR = "config";
|
|
844
|
-
const TASK_RUNNER_ROOT_CONFIG_FILENAME = "task.config.js";
|
|
845
|
-
/** Default root config path: `config/task.config.js` */
|
|
846
|
-
const TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH = `${TASK_RUNNER_CONFIG_DIR}/${TASK_RUNNER_ROOT_CONFIG_FILENAME}`;
|
|
847
|
-
/** Legacy fallback at project root */
|
|
848
|
-
const TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH = TASK_RUNNER_ROOT_CONFIG_FILENAME;
|
|
849
|
-
/** Per-task config filename inside each folder under `taskDir` */
|
|
850
|
-
const TASK_LOCAL_PACKAGE_CONFIG_FILENAME = "task.config.js";
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* Directory containing a config file — anchor for paths declared **inside** that file.
|
|
854
|
-
*/
|
|
855
|
-
function getConfigFileDir(configFilePath) {
|
|
856
|
-
return dirname(configFilePath);
|
|
857
|
-
}
|
|
895
|
+
const RUNTIME_STATE_SHARD_DIR = "runtime-state";
|
|
858
896
|
/**
|
|
859
|
-
* Resolve
|
|
897
|
+
* Resolve sharded runtime state layout from {@link CreateServiceRuntimeStateStoreOptions.filePath}.
|
|
860
898
|
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
899
|
+
* | `filePath` | Shard dir | Legacy |
|
|
900
|
+
* |-------------------------|-----------------------------------|---------------|
|
|
901
|
+
* | `.../state.json` | `.../runtime-state/` | `state.json` |
|
|
902
|
+
* | `.../runtime-state/` | same directory | none |
|
|
865
903
|
*/
|
|
866
|
-
function
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
904
|
+
function resolveRuntimeStatePaths(filePath) {
|
|
905
|
+
if (filePath.endsWith(".json")) {
|
|
906
|
+
return {
|
|
907
|
+
stateDir: join(dirname(filePath), RUNTIME_STATE_SHARD_DIR),
|
|
908
|
+
legacyFilePath: filePath,
|
|
909
|
+
};
|
|
870
910
|
}
|
|
871
|
-
return
|
|
911
|
+
return {
|
|
912
|
+
stateDir: filePath,
|
|
913
|
+
legacyFilePath: null,
|
|
914
|
+
};
|
|
872
915
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
* Same rules as {@link resolvePathFromConfigFile}, but base is already known.
|
|
876
|
-
*/
|
|
877
|
-
function resolvePathFromBaseDir(baseDir, path) {
|
|
878
|
-
const trimmed = path.trim();
|
|
879
|
-
if (isAbsolute(trimmed)) {
|
|
880
|
-
return trimmed;
|
|
881
|
-
}
|
|
882
|
-
return resolve(baseDir, trimmed);
|
|
916
|
+
function runtimeStateShardPath(stateDir, sliceKey) {
|
|
917
|
+
return join(stateDir, `${sliceKey}.json`);
|
|
883
918
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
function resolvePathFromCwd(cwd, path) {
|
|
888
|
-
const trimmed = path.trim();
|
|
889
|
-
if (isAbsolute(trimmed)) {
|
|
890
|
-
return trimmed;
|
|
891
|
-
}
|
|
892
|
-
return resolve(cwd, trimmed);
|
|
919
|
+
|
|
920
|
+
function isPlainObject(value) {
|
|
921
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
893
922
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
return
|
|
898
|
-
}
|
|
899
|
-
catch {
|
|
900
|
-
return false;
|
|
923
|
+
function mergeSlice(defaults, key, patchValue) {
|
|
924
|
+
const base = defaults[key];
|
|
925
|
+
if (isPlainObject(base) && isPlainObject(patchValue)) {
|
|
926
|
+
return { ...base, ...patchValue };
|
|
901
927
|
}
|
|
928
|
+
return patchValue;
|
|
902
929
|
}
|
|
903
930
|
/**
|
|
904
|
-
*
|
|
931
|
+
* JSON runtime state with **one file per top-level slice** (log-persist registry pattern).
|
|
932
|
+
* Eliminates cross-slice lost updates when guard/scheduler/worker write concurrently.
|
|
905
933
|
*/
|
|
906
|
-
|
|
907
|
-
const {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
934
|
+
function createShardedJsonFileStateStore(options) {
|
|
935
|
+
const { defaultState, nestedKeys, updatedAtKey } = options;
|
|
936
|
+
const { stateDir, legacyFilePath } = resolveRuntimeStatePaths(options.filePath);
|
|
937
|
+
const shardStores = new Map();
|
|
938
|
+
function shardStore(sliceKey) {
|
|
939
|
+
const key = String(sliceKey);
|
|
940
|
+
let store = shardStores.get(key);
|
|
941
|
+
if (store == null) {
|
|
942
|
+
store = createJsonFileStateStore({
|
|
943
|
+
filePath: runtimeStateShardPath(stateDir, key),
|
|
944
|
+
defaultState: defaultState[sliceKey],
|
|
945
|
+
});
|
|
946
|
+
shardStores.set(key, store);
|
|
947
|
+
}
|
|
948
|
+
return store;
|
|
911
949
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
950
|
+
async function readUpdatedAtFromShardMtimes() {
|
|
951
|
+
let maxMtimeMs = 0;
|
|
952
|
+
for (const sliceKey of nestedKeys) {
|
|
953
|
+
try {
|
|
954
|
+
const shardStat = await stat(runtimeStateShardPath(stateDir, String(sliceKey)));
|
|
955
|
+
maxMtimeMs = Math.max(maxMtimeMs, shardStat.mtimeMs);
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
if (error.code !== "ENOENT") {
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (maxMtimeMs <= 0) {
|
|
964
|
+
return defaultState[updatedAtKey];
|
|
916
965
|
}
|
|
966
|
+
return new Date(maxMtimeMs).toISOString();
|
|
917
967
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
968
|
+
async function migrateLegacyMonolithicIfPresent() {
|
|
969
|
+
if (legacyFilePath == null) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
let raw;
|
|
973
|
+
try {
|
|
974
|
+
raw = await readFile(legacyFilePath, "utf8");
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
if (error.code === "ENOENT") {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
let parsed;
|
|
983
|
+
try {
|
|
984
|
+
parsed = JSON.parse(raw);
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
await unlink(legacyFilePath).catch(() => undefined);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
await mkdir(stateDir, { recursive: true });
|
|
991
|
+
for (const sliceKey of nestedKeys) {
|
|
992
|
+
const slice = parsed[sliceKey];
|
|
993
|
+
if (slice != null) {
|
|
994
|
+
await shardStore(sliceKey).write(slice);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
await unlink(legacyFilePath).catch(() => undefined);
|
|
925
998
|
}
|
|
926
|
-
return
|
|
999
|
+
return {
|
|
1000
|
+
async read() {
|
|
1001
|
+
await mkdir(stateDir, { recursive: true });
|
|
1002
|
+
await migrateLegacyMonolithicIfPresent();
|
|
1003
|
+
const merged = { ...defaultState };
|
|
1004
|
+
for (const sliceKey of nestedKeys) {
|
|
1005
|
+
merged[sliceKey] = (await shardStore(sliceKey).read());
|
|
1006
|
+
}
|
|
1007
|
+
{
|
|
1008
|
+
merged[updatedAtKey] = (await readUpdatedAtFromShardMtimes());
|
|
1009
|
+
}
|
|
1010
|
+
return merged;
|
|
1011
|
+
},
|
|
1012
|
+
async write(patch) {
|
|
1013
|
+
await mkdir(stateDir, { recursive: true });
|
|
1014
|
+
await migrateLegacyMonolithicIfPresent();
|
|
1015
|
+
for (const sliceKey of nestedKeys) {
|
|
1016
|
+
const patchSlice = patch[sliceKey];
|
|
1017
|
+
if (patchSlice == null) {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const current = await shardStore(sliceKey).read();
|
|
1021
|
+
const nextSlice = mergeSlice(defaultState, sliceKey, {
|
|
1022
|
+
...current,
|
|
1023
|
+
...patchSlice,
|
|
1024
|
+
});
|
|
1025
|
+
await shardStore(sliceKey).write(nextSlice);
|
|
1026
|
+
}
|
|
1027
|
+
return this.read();
|
|
1028
|
+
},
|
|
1029
|
+
async reset() {
|
|
1030
|
+
await mkdir(stateDir, { recursive: true });
|
|
1031
|
+
for (const sliceKey of nestedKeys) {
|
|
1032
|
+
await shardStore(sliceKey).reset();
|
|
1033
|
+
}
|
|
1034
|
+
if (legacyFilePath != null) {
|
|
1035
|
+
await unlink(legacyFilePath).catch(() => undefined);
|
|
1036
|
+
}
|
|
1037
|
+
return this.read();
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
927
1040
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1041
|
+
|
|
1042
|
+
const EPOCH_ISO = new Date(0).toISOString();
|
|
1043
|
+
function createDefaultServiceRuntimeState(options) {
|
|
1044
|
+
const readiness = {
|
|
1045
|
+
status: "pending",
|
|
1046
|
+
checkedAt: null,
|
|
1047
|
+
message: null,
|
|
1048
|
+
checks: [],
|
|
1049
|
+
};
|
|
1050
|
+
const guard = {
|
|
1051
|
+
nodeId: null,
|
|
1052
|
+
lastCheckAt: null,
|
|
1053
|
+
lastDecision: null,
|
|
1054
|
+
lastSpawnAt: null,
|
|
1055
|
+
spawnCount: 0,
|
|
1056
|
+
};
|
|
1057
|
+
const scheduler = {
|
|
1058
|
+
lastTickAt: null,
|
|
1059
|
+
lastPostedCount: 0,
|
|
1060
|
+
lastFired: {},
|
|
1061
|
+
};
|
|
1062
|
+
const worker = {
|
|
1063
|
+
pid: null,
|
|
1064
|
+
spawnedAt: null,
|
|
1065
|
+
ready: false,
|
|
1066
|
+
registeredTasks: [],
|
|
1067
|
+
readyAt: null,
|
|
1068
|
+
};
|
|
1069
|
+
return {
|
|
1070
|
+
updatedAt: EPOCH_ISO,
|
|
1071
|
+
readiness,
|
|
1072
|
+
guard,
|
|
1073
|
+
scheduler,
|
|
1074
|
+
worker,
|
|
1075
|
+
registry: { ...EMPTY_REGISTRY_SLOT_RUNTIME_STATE },
|
|
1076
|
+
...(options.extraDefaults ?? {}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/** Sharded JSON runtime state (L3 blueprint) — one file per slice under `runtime-state/`. */
|
|
1080
|
+
function createServiceRuntimeStateStore(options) {
|
|
1081
|
+
const defaultState = createDefaultServiceRuntimeState(options);
|
|
1082
|
+
const nestedKeys = [
|
|
1083
|
+
"readiness",
|
|
1084
|
+
"guard",
|
|
1085
|
+
"scheduler",
|
|
1086
|
+
"worker",
|
|
1087
|
+
"registry",
|
|
1088
|
+
...Object.keys(options.extraDefaults ?? {}),
|
|
1089
|
+
];
|
|
1090
|
+
const store = createShardedJsonFileStateStore({
|
|
1091
|
+
filePath: options.filePath,
|
|
1092
|
+
defaultState,
|
|
1093
|
+
nestedKeys,
|
|
1094
|
+
updatedAtKey: "updatedAt",
|
|
1095
|
+
});
|
|
1096
|
+
return {
|
|
1097
|
+
readRuntimeState: () => store.read(),
|
|
1098
|
+
writeRuntimeState: (patch) => store.write(patch),
|
|
1099
|
+
resetRuntimeStateForStartup: () => store.reset(),
|
|
1100
|
+
finishRunnerTick: async ({ nowMs, lastFired, postedCount, extra }) => {
|
|
1101
|
+
await store.write({
|
|
1102
|
+
scheduler: {
|
|
1103
|
+
lastTickAt: new Date(nowMs).toISOString(),
|
|
1104
|
+
lastFired,
|
|
1105
|
+
...(postedCount != null ? { lastPostedCount: postedCount } : {}),
|
|
1106
|
+
},
|
|
1107
|
+
...extra,
|
|
1108
|
+
});
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
932
1111
|
}
|
|
933
1112
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1113
|
+
const LOG_TOPIC_TRIGGER = "trigger";
|
|
1114
|
+
/** Default main-loop interval for {@link TriggerNode} when `loopIntervalMs` is omitted (ms). */
|
|
1115
|
+
const TRIGGER_LOOP_INTERVAL_MS = 60_000;
|
|
1116
|
+
/** Default extra retries after first failure within one runner tick. */
|
|
1117
|
+
const TRIGGER_RUNNER_DEFAULT_RETRY_COUNT = 3;
|
|
1118
|
+
/** Default delay between retry attempts within the same tick (ms). */
|
|
1119
|
+
const TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS = 0;
|
|
1120
|
+
|
|
1121
|
+
const runnerRegistry = new Map();
|
|
1122
|
+
let registrationClosed = false;
|
|
1123
|
+
function normalizeRunnerKey(key) {
|
|
1124
|
+
const normalized = key.trim();
|
|
1125
|
+
if (normalized.length === 0) {
|
|
1126
|
+
throw new Error("[TriggerManager] key must be a non-empty string");
|
|
937
1127
|
}
|
|
938
|
-
|
|
939
|
-
return typeof record.taskParamsValidator === "function" && typeof record.taskFn === "function";
|
|
1128
|
+
return normalized;
|
|
940
1129
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
function normalizeRepoContextModule(moduleExports, taskTypeKey, exportName) {
|
|
949
|
-
if (moduleExports == null) {
|
|
950
|
-
return null;
|
|
951
|
-
}
|
|
952
|
-
const record = moduleExports;
|
|
953
|
-
const namedExport = exportName != null && exportName !== "" ? record[exportName] : undefined;
|
|
954
|
-
const defaultExport = record.default;
|
|
955
|
-
const candidate = namedExport ?? defaultExport ?? moduleExports;
|
|
956
|
-
let taskParamsValidator;
|
|
957
|
-
let taskFn;
|
|
958
|
-
if (typeof candidate === "function") {
|
|
959
|
-
taskFn = candidate;
|
|
960
|
-
taskParamsValidator = record.taskParamsValidator;
|
|
961
|
-
}
|
|
962
|
-
else if (typeof candidate === "object" && candidate != null) {
|
|
963
|
-
const ctx = candidate;
|
|
964
|
-
taskParamsValidator = ctx.taskParamsValidator;
|
|
965
|
-
taskFn = ctx.taskFn;
|
|
1130
|
+
function tryNormalizeRunnerKey(key) {
|
|
1131
|
+
const normalized = key.trim();
|
|
1132
|
+
return normalized.length > 0 ? normalized : null;
|
|
1133
|
+
}
|
|
1134
|
+
function validateRunnerOptions(options) {
|
|
1135
|
+
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 1) {
|
|
1136
|
+
throw new Error(`[TriggerManager] intervalMs must be a positive number, got ${options.intervalMs}`);
|
|
966
1137
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
return null;
|
|
1138
|
+
const retryCount = options.retryCount ?? TRIGGER_RUNNER_DEFAULT_RETRY_COUNT;
|
|
1139
|
+
if (!Number.isInteger(retryCount) || retryCount < 0) {
|
|
1140
|
+
throw new Error(`[TriggerManager] retryCount must be a non-negative integer, got ${retryCount}`);
|
|
971
1141
|
}
|
|
972
|
-
|
|
973
|
-
|
|
1142
|
+
const initialDelayMs = options.initialDelayMs ?? 0;
|
|
1143
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs < 0) {
|
|
1144
|
+
throw new Error(`[TriggerManager] initialDelayMs must be a non-negative number, got ${initialDelayMs}`);
|
|
974
1145
|
}
|
|
975
|
-
const
|
|
976
|
-
if (
|
|
977
|
-
|
|
1146
|
+
const retryDelayMs = options.retryDelayMs ?? TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS;
|
|
1147
|
+
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
1148
|
+
throw new Error(`[TriggerManager] retryDelayMs must be a non-negative number, got ${retryDelayMs}`);
|
|
978
1149
|
}
|
|
979
|
-
if (
|
|
980
|
-
|
|
1150
|
+
if (options.timeoutMs != null) {
|
|
1151
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1) {
|
|
1152
|
+
throw new Error(`[TriggerManager] timeoutMs must be a positive number when set, got ${options.timeoutMs}`);
|
|
1153
|
+
}
|
|
981
1154
|
}
|
|
982
|
-
return {
|
|
983
|
-
taskParamsValidator: taskParamsValidator,
|
|
984
|
-
taskFn: fn,
|
|
985
|
-
};
|
|
986
1155
|
}
|
|
987
|
-
function
|
|
988
|
-
|
|
989
|
-
return null;
|
|
990
|
-
}
|
|
991
|
-
const entry = scripts.find((script) => script.details.isEntry === true);
|
|
992
|
-
return entry ?? scripts[0];
|
|
1156
|
+
function delay(ms) {
|
|
1157
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
993
1158
|
}
|
|
994
|
-
function
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
return
|
|
998
|
-
}
|
|
999
|
-
if (source != null && source !== "") {
|
|
1000
|
-
return `source:${source.length}:${source.slice(0, 64)}`;
|
|
1159
|
+
async function invokeRunnerFn(fn, ctx, timeoutMs) {
|
|
1160
|
+
if (timeoutMs == null) {
|
|
1161
|
+
await fn(ctx);
|
|
1162
|
+
return;
|
|
1001
1163
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1164
|
+
let timeoutHandle;
|
|
1165
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1166
|
+
timeoutHandle = setTimeout(() => {
|
|
1167
|
+
reject(new Error(`Runner timed out after ${timeoutMs}ms`));
|
|
1168
|
+
}, timeoutMs);
|
|
1169
|
+
});
|
|
1170
|
+
try {
|
|
1171
|
+
await Promise.race([Promise.resolve(fn(ctx)), timeoutPromise]);
|
|
1004
1172
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const hasModulePath = details.modulePath != null && details.modulePath.trim() !== "";
|
|
1010
|
-
return hasSource || hasModulePath;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const importedModuleCache = new Map();
|
|
1014
|
-
async function importFromFilePath(modulePath) {
|
|
1015
|
-
return import(toFileImportHref(modulePath));
|
|
1016
|
-
}
|
|
1017
|
-
/** Resolve repo `details.url` to a value suitable for dynamic `import()`. */
|
|
1018
|
-
function resolveRepoEntryHref(url, cwd = process.cwd()) {
|
|
1019
|
-
const trimmed = url.trim();
|
|
1020
|
-
if (trimmed.includes("://")) {
|
|
1021
|
-
return trimmed;
|
|
1173
|
+
finally {
|
|
1174
|
+
if (timeoutHandle != null) {
|
|
1175
|
+
clearTimeout(timeoutHandle);
|
|
1176
|
+
}
|
|
1022
1177
|
}
|
|
1023
|
-
return toFileImportHref(resolvePathFromCwd(cwd, trimmed));
|
|
1024
|
-
}
|
|
1025
|
-
async function importFromSource(source, cacheKey) {
|
|
1026
|
-
const hash = createHash("sha256").update(source).digest("hex").slice(0, 16);
|
|
1027
|
-
const dir = join(tmpdir(), "target-supabase-sdk", "repo-scripts");
|
|
1028
|
-
await mkdir(dir, { recursive: true });
|
|
1029
|
-
const filePath = join(dir, `${cacheKey}-${hash}.mjs`);
|
|
1030
|
-
await writeFile(filePath, source, "utf8");
|
|
1031
|
-
return importFromFilePath(filePath);
|
|
1032
1178
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
logger.info("命中 Repo 脚本模块缓存", { topic: "repo", data: { cacheKey } });
|
|
1039
|
-
return cached;
|
|
1040
|
-
}
|
|
1041
|
-
const { source, modulePath, exportName } = script.details;
|
|
1042
|
-
try {
|
|
1043
|
-
let moduleExports;
|
|
1044
|
-
if (source != null && source.trim() !== "") {
|
|
1045
|
-
logger.info("从 source 动态加载 Repo 脚本", { topic: "repo", data: { scriptId: script.id } });
|
|
1046
|
-
moduleExports = await importFromSource(source, cacheKey);
|
|
1179
|
+
/** In-process registry and tick loop for {@link TriggerNode}. */
|
|
1180
|
+
const TriggerManager = {
|
|
1181
|
+
registerRunner(options) {
|
|
1182
|
+
if (registrationClosed) {
|
|
1183
|
+
throw new Error("[TriggerManager] Registration closed — call registerRunner before TriggerNode.start()");
|
|
1047
1184
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1185
|
+
const key = normalizeRunnerKey(options.key);
|
|
1186
|
+
validateRunnerOptions(options);
|
|
1187
|
+
if (typeof options.fn !== "function") {
|
|
1188
|
+
throw new Error(`[TriggerManager] fn must be a function (runner key: ${key})`);
|
|
1189
|
+
}
|
|
1190
|
+
if (runnerRegistry.has(key)) {
|
|
1191
|
+
throw new Error(`[TriggerManager] Duplicate runner key: ${key}`);
|
|
1192
|
+
}
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
runnerRegistry.set(key, {
|
|
1195
|
+
key,
|
|
1196
|
+
intervalMs: options.intervalMs,
|
|
1197
|
+
fn: options.fn,
|
|
1198
|
+
retryCount: options.retryCount ?? TRIGGER_RUNNER_DEFAULT_RETRY_COUNT,
|
|
1199
|
+
retryDelayMs: options.retryDelayMs ?? TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS,
|
|
1200
|
+
timeoutMs: options.timeoutMs,
|
|
1201
|
+
nextRunAt: now + (options.initialDelayMs ?? 0),
|
|
1202
|
+
running: false,
|
|
1203
|
+
});
|
|
1204
|
+
},
|
|
1205
|
+
hasRunner(key) {
|
|
1206
|
+
const normalized = tryNormalizeRunnerKey(key);
|
|
1207
|
+
return normalized != null && runnerRegistry.has(normalized);
|
|
1208
|
+
},
|
|
1209
|
+
unregisterRunner(key) {
|
|
1210
|
+
const normalized = tryNormalizeRunnerKey(key);
|
|
1211
|
+
if (normalized == null) {
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
const runner = runnerRegistry.get(normalized);
|
|
1215
|
+
if (runner == null) {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
if (runner.running) {
|
|
1219
|
+
throw new Error(`[TriggerManager] Cannot unregister running runner: ${normalized}`);
|
|
1220
|
+
}
|
|
1221
|
+
return runnerRegistry.delete(normalized);
|
|
1222
|
+
},
|
|
1223
|
+
getRunnerKeys() {
|
|
1224
|
+
return [...runnerRegistry.keys()];
|
|
1225
|
+
},
|
|
1226
|
+
getRunnersBelowLoopInterval(loopIntervalMs = TRIGGER_LOOP_INTERVAL_MS) {
|
|
1227
|
+
return [...runnerRegistry.values()]
|
|
1228
|
+
.filter((runner) => runner.intervalMs < loopIntervalMs)
|
|
1229
|
+
.map((runner) => ({ key: runner.key, intervalMs: runner.intervalMs }));
|
|
1230
|
+
},
|
|
1231
|
+
/** Called by TriggerNode during bootstrap — blocks further registerRunner calls. */
|
|
1232
|
+
closeRegistration() {
|
|
1233
|
+
registrationClosed = true;
|
|
1234
|
+
},
|
|
1235
|
+
/** Re-open registration (tests only). */
|
|
1236
|
+
_resetRegistrationForTests() {
|
|
1237
|
+
registrationClosed = false;
|
|
1238
|
+
},
|
|
1239
|
+
/**
|
|
1240
|
+
* Remove all runners. Throws if any runner is still executing.
|
|
1241
|
+
* Wait for in-flight ticks to finish before calling in tests.
|
|
1242
|
+
*/
|
|
1243
|
+
clearRunners() {
|
|
1244
|
+
for (const runner of runnerRegistry.values()) {
|
|
1245
|
+
if (runner.running) {
|
|
1246
|
+
throw new Error(`[TriggerManager] Cannot clearRunners while runner is running: ${runner.key}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
runnerRegistry.clear();
|
|
1250
|
+
registrationClosed = false;
|
|
1251
|
+
},
|
|
1252
|
+
async tick(ctx) {
|
|
1253
|
+
const { loopTraceId, nodeId } = ctx;
|
|
1254
|
+
const loopScope = createScope({ module: "tickRunners", traceId: loopTraceId, labels: { nodeId } });
|
|
1255
|
+
const logger = createLogger({ scope: loopScope });
|
|
1256
|
+
const now = Date.now();
|
|
1257
|
+
const runners = [...runnerRegistry.values()];
|
|
1258
|
+
if (runners.length === 0) {
|
|
1259
|
+
logger.debug("無 runner 可調度", { topic: LOG_TOPIC_TRIGGER });
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
// Overlap: if fn is still running when due, skip that tick (no catch-up / backlog).
|
|
1263
|
+
// Safe while intervals are large; missed ticks increase if fn duration exceeds intervalMs.
|
|
1264
|
+
const dueRunners = runners.filter((runner) => !runner.running && now >= runner.nextRunAt);
|
|
1265
|
+
if (dueRunners.length === 0) {
|
|
1266
|
+
logger.debug("本輪無到期 runner", { topic: LOG_TOPIC_TRIGGER });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
logger.debug("調度到期 runner", {
|
|
1270
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1271
|
+
data: { due: dueRunners.map((r) => r.key) },
|
|
1272
|
+
});
|
|
1273
|
+
const results = await Promise.all(dueRunners.map((runner) => runRunner(ctx, runner)));
|
|
1274
|
+
const executed = results.filter(Boolean).length;
|
|
1275
|
+
if (executed > 0) {
|
|
1276
|
+
logger.info("Runner 調度完成", {
|
|
1277
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1278
|
+
data: { due: dueRunners.length, executed },
|
|
1052
1279
|
});
|
|
1053
|
-
moduleExports = await importFromFilePath(modulePath);
|
|
1054
1280
|
}
|
|
1055
1281
|
else {
|
|
1056
|
-
logger.warn("
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
topic: "repo",
|
|
1063
|
-
data: { scriptId: script.id, exportName: exportName ?? "default" },
|
|
1282
|
+
logger.warn("本輪到期 runner 均未成功", {
|
|
1283
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1284
|
+
data: {
|
|
1285
|
+
due: dueRunners.length,
|
|
1286
|
+
runnerKeys: dueRunners.map((r) => r.key),
|
|
1287
|
+
},
|
|
1064
1288
|
});
|
|
1065
|
-
return null;
|
|
1066
1289
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1290
|
+
},
|
|
1291
|
+
};
|
|
1292
|
+
async function runRunner(ctx, runner) {
|
|
1293
|
+
const { loopTraceId, nodeId } = ctx;
|
|
1294
|
+
if (runner.running) {
|
|
1295
|
+
return false;
|
|
1070
1296
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1297
|
+
runner.running = true;
|
|
1298
|
+
const runnerLogger = createLogger({
|
|
1299
|
+
scope: withModule(createScope({ module: "runRunner", traceId: loopTraceId, labels: { nodeId, runnerKey: runner.key } }), runner.key),
|
|
1300
|
+
});
|
|
1301
|
+
const maxAttempts = 1 + runner.retryCount;
|
|
1302
|
+
let succeeded = false;
|
|
1303
|
+
try {
|
|
1304
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1305
|
+
try {
|
|
1306
|
+
runnerLogger.info("Runner 觸發", {
|
|
1307
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1308
|
+
data: { attempt, maxAttempts },
|
|
1309
|
+
});
|
|
1310
|
+
await invokeRunnerFn(runner.fn, {
|
|
1311
|
+
loopTraceId,
|
|
1312
|
+
nodeId,
|
|
1313
|
+
runnerKey: runner.key,
|
|
1314
|
+
logger: runnerLogger,
|
|
1315
|
+
attempt,
|
|
1316
|
+
maxAttempts,
|
|
1317
|
+
}, runner.timeoutMs);
|
|
1318
|
+
runnerLogger.success("Runner 完成", {
|
|
1319
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1320
|
+
data: { attempt, maxAttempts },
|
|
1321
|
+
});
|
|
1322
|
+
succeeded = true;
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
catch (error) {
|
|
1326
|
+
const message = getErrorMessage(error);
|
|
1327
|
+
if (attempt < maxAttempts) {
|
|
1328
|
+
runnerLogger.warn("Runner 失敗,準備重試", {
|
|
1329
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1330
|
+
data: { attempt, maxAttempts, error: message },
|
|
1331
|
+
});
|
|
1332
|
+
if (runner.retryDelayMs > 0) {
|
|
1333
|
+
await delay(runner.retryDelayMs);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
runnerLogger.error("Runner 重試用盡仍失敗", {
|
|
1338
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1339
|
+
data: { attempt, maxAttempts, error: message },
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
finally {
|
|
1346
|
+
runner.running = false;
|
|
1347
|
+
runner.nextRunAt = Date.now() + runner.intervalMs;
|
|
1348
|
+
}
|
|
1349
|
+
return succeeded;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Run registry slot ownership check and optionally SIGTERM the parent main process.
|
|
1354
|
+
* Used by guard / supervisor TriggerNode runners across L3 services.
|
|
1355
|
+
*/
|
|
1356
|
+
async function applyRegistrySlotGuardStep(input) {
|
|
1357
|
+
const result = await runRegistrySlotGuardCheck({
|
|
1358
|
+
serviceValue: input.serviceValue,
|
|
1359
|
+
serviceId: input.serviceId,
|
|
1360
|
+
});
|
|
1361
|
+
const registryPatch = registrySlotRuntimePatchFromGuardResult(result, input.checkedAt);
|
|
1362
|
+
if (result.shouldShutdown) {
|
|
1363
|
+
input.logger.critical("Registry slot lost — shutting down service", {
|
|
1364
|
+
topic: input.logTopic ?? "guard",
|
|
1365
|
+
data: {
|
|
1366
|
+
traceId: input.traceId,
|
|
1367
|
+
serviceId: input.serviceId,
|
|
1368
|
+
serviceValue: input.serviceValue,
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
process.kill(process.ppid, "SIGTERM");
|
|
1372
|
+
return { continueTick: false, registryPatch };
|
|
1373
|
+
}
|
|
1374
|
+
if (result.error != null) {
|
|
1375
|
+
input.logger.warn("Registry slot check failed (transient) — skipping enforcement this tick", {
|
|
1376
|
+
topic: input.logTopic ?? "guard",
|
|
1377
|
+
data: {
|
|
1378
|
+
traceId: input.traceId,
|
|
1379
|
+
serviceId: input.serviceId,
|
|
1380
|
+
message: result.error,
|
|
1381
|
+
},
|
|
1075
1382
|
});
|
|
1076
|
-
return null;
|
|
1077
1383
|
}
|
|
1384
|
+
return { continueTick: true, registryPatch };
|
|
1078
1385
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1386
|
+
|
|
1387
|
+
let lastWorkerSpawnAt = 0;
|
|
1388
|
+
function getWorkerSpawnCooldownLastAt() {
|
|
1389
|
+
return lastWorkerSpawnAt;
|
|
1390
|
+
}
|
|
1391
|
+
function markWorkerSpawned(at = Date.now()) {
|
|
1392
|
+
lastWorkerSpawnAt = at;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* One guard-runner tick: registry slot check, TaskNode liveness, optional worker
|
|
1397
|
+
* respawn, Service runtime heartbeat rollup.
|
|
1398
|
+
*/
|
|
1399
|
+
async function runServiceGuardTick(input) {
|
|
1400
|
+
const { serviceValue, serviceId, nodeId, loopTraceId, logger, logTopic = "guard", checkedAt, nowMs, taskNodeStaleMs, workerSpawnCooldownMs, maxNodes = 200, spawnWorker, getLastWorkerSpawnAt = getWorkerSpawnCooldownLastAt, markWorkerSpawned: markWorkerSpawnedAt = markWorkerSpawned, } = input;
|
|
1401
|
+
const continueTick = true;
|
|
1402
|
+
let registryPatch;
|
|
1403
|
+
let decision = "idle";
|
|
1404
|
+
if (serviceId != null) {
|
|
1405
|
+
const guard = await applyRegistrySlotGuardStep({
|
|
1406
|
+
serviceValue,
|
|
1407
|
+
serviceId,
|
|
1408
|
+
checkedAt,
|
|
1409
|
+
logger,
|
|
1410
|
+
logTopic,
|
|
1411
|
+
traceId: loopTraceId,
|
|
1412
|
+
});
|
|
1413
|
+
registryPatch = guard.registryPatch;
|
|
1414
|
+
if (!guard.continueTick) {
|
|
1415
|
+
return { continueTick: false, decision: "slot_lost", nodes: [], registryPatch };
|
|
1416
|
+
}
|
|
1417
|
+
decision = "slot_ok";
|
|
1418
|
+
}
|
|
1419
|
+
const { data: nodes = [] } = await scanTargetList({
|
|
1420
|
+
category: CategoryNode.NODE,
|
|
1421
|
+
maxRows: maxNodes,
|
|
1422
|
+
});
|
|
1423
|
+
const liveness = evaluateBusyNodeLiveness(nodes, {
|
|
1424
|
+
staleMs: taskNodeStaleMs,
|
|
1425
|
+
now: nowMs,
|
|
1426
|
+
excludeNodeId: nodeId,
|
|
1427
|
+
onlyFreshCandidates: true,
|
|
1428
|
+
});
|
|
1429
|
+
const ageMs = liveness.freshest?.ageMs ?? Number.POSITIVE_INFINITY;
|
|
1430
|
+
if (liveness.healthy) {
|
|
1431
|
+
decision = "healthy";
|
|
1432
|
+
}
|
|
1433
|
+
else if (nowMs - getLastWorkerSpawnAt() < workerSpawnCooldownMs) {
|
|
1434
|
+
decision = "spawn_cooldown";
|
|
1435
|
+
logger.warn("TaskNode unavailable but spawn cooldown active", {
|
|
1436
|
+
topic: logTopic,
|
|
1437
|
+
data: {
|
|
1438
|
+
traceId: loopTraceId,
|
|
1439
|
+
ageMs,
|
|
1440
|
+
staleMs: taskNodeStaleMs,
|
|
1441
|
+
cooldownMs: workerSpawnCooldownMs,
|
|
1442
|
+
},
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
decision = "spawn_worker";
|
|
1447
|
+
logger.warn("TaskNode stale — restarting worker", {
|
|
1448
|
+
topic: logTopic,
|
|
1449
|
+
data: {
|
|
1450
|
+
traceId: loopTraceId,
|
|
1451
|
+
peerWorkerCount: liveness.busyWorkerCount,
|
|
1452
|
+
freshPeerCount: liveness.freshWorkerCount,
|
|
1453
|
+
staleMs: taskNodeStaleMs,
|
|
1454
|
+
},
|
|
1455
|
+
});
|
|
1456
|
+
await spawnWorker("guard:restart-unhealthy-tasknode");
|
|
1457
|
+
markWorkerSpawnedAt(nowMs);
|
|
1086
1458
|
}
|
|
1087
1459
|
try {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
data: { url, exportName: exportName ?? "default" },
|
|
1095
|
-
});
|
|
1096
|
-
return null;
|
|
1097
|
-
}
|
|
1098
|
-
const loaded = { context, contentHash: cacheKey };
|
|
1099
|
-
importedModuleCache.set(cacheKey, loaded);
|
|
1100
|
-
return loaded;
|
|
1460
|
+
await patchServiceRuntime({
|
|
1461
|
+
serviceValue,
|
|
1462
|
+
serviceId: serviceId ?? undefined,
|
|
1463
|
+
nodes,
|
|
1464
|
+
lastHeartBeat: nowMs,
|
|
1465
|
+
});
|
|
1101
1466
|
}
|
|
1102
1467
|
catch (error) {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1468
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1469
|
+
logger.warn("Failed to patch Service runtime rollup", {
|
|
1470
|
+
topic: logTopic,
|
|
1471
|
+
data: { traceId: loopTraceId, message },
|
|
1106
1472
|
});
|
|
1107
|
-
return null;
|
|
1108
1473
|
}
|
|
1109
|
-
}
|
|
1110
|
-
/** Clear in-memory imported module cache (tests / hot reload). */
|
|
1111
|
-
function clearRepoScriptModuleCache() {
|
|
1112
|
-
importedModuleCache.clear();
|
|
1474
|
+
return { continueTick, decision, nodes, registryPatch };
|
|
1113
1475
|
}
|
|
1114
1476
|
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1477
|
+
/** Default TriggerManager runner key for {@link registerServiceGuardRunner}. */
|
|
1478
|
+
const SERVICE_GUARD_RUNNER_KEY = "service-guard";
|
|
1479
|
+
|
|
1480
|
+
async function runServiceGuardRunner(ctx, options) {
|
|
1481
|
+
const { loopTraceId, nodeId, logger } = ctx;
|
|
1482
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
1483
|
+
const checkedAt = new Date(nowMs).toISOString();
|
|
1484
|
+
const serviceId = await options.getServiceId();
|
|
1485
|
+
const result = await runServiceGuardTick({
|
|
1486
|
+
serviceValue: options.serviceValue,
|
|
1487
|
+
serviceId,
|
|
1488
|
+
nodeId,
|
|
1489
|
+
loopTraceId,
|
|
1490
|
+
logger,
|
|
1491
|
+
logTopic: options.logTopic,
|
|
1492
|
+
checkedAt,
|
|
1493
|
+
nowMs,
|
|
1494
|
+
taskNodeStaleMs: options.taskNodeStaleMs,
|
|
1495
|
+
workerSpawnCooldownMs: options.workerSpawnCooldownMs,
|
|
1496
|
+
maxNodes: options.maxNodes,
|
|
1497
|
+
spawnWorker: options.spawnWorker,
|
|
1126
1498
|
});
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1499
|
+
if (result.registryPatch != null) {
|
|
1500
|
+
await options.onRegistryPatch(result.registryPatch);
|
|
1501
|
+
}
|
|
1502
|
+
if (!result.continueTick) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
await options.onGuardPatch({
|
|
1506
|
+
nodeId,
|
|
1507
|
+
lastCheckAt: checkedAt,
|
|
1508
|
+
lastDecision: result.decision,
|
|
1509
|
+
});
|
|
1510
|
+
if (options.onDecision != null) {
|
|
1511
|
+
await options.onDecision(result.decision);
|
|
1137
1512
|
}
|
|
1138
|
-
return (data ?? []);
|
|
1139
1513
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1514
|
+
/** Register the standard L3 guard runner (registry slot + TaskNode + Service runtime). */
|
|
1515
|
+
function registerServiceGuardRunner(options) {
|
|
1516
|
+
TriggerManager.registerRunner({
|
|
1517
|
+
key: options.runnerKey ?? SERVICE_GUARD_RUNNER_KEY,
|
|
1518
|
+
intervalMs: options.intervalMs,
|
|
1519
|
+
initialDelayMs: options.initialDelayMs,
|
|
1520
|
+
fn: (ctx) => runServiceGuardRunner(ctx, options),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** Run readiness checks, persist report via callback, fail-fast when not ok. */
|
|
1525
|
+
async function runReadinessGate(input) {
|
|
1526
|
+
const { checks, onReport, logger, logTopic = "guard" } = input;
|
|
1527
|
+
logger.info("Running readiness checks", { topic: logTopic });
|
|
1528
|
+
const report = await runReadinessChecks(checks);
|
|
1529
|
+
await onReport(report);
|
|
1530
|
+
if (!report.ok) {
|
|
1531
|
+
logger.error("Readiness checks failed", {
|
|
1532
|
+
topic: logTopic,
|
|
1533
|
+
data: { message: report.message, checks: report.checks },
|
|
1534
|
+
});
|
|
1535
|
+
throw new Error(report.message ?? "Readiness checks failed");
|
|
1143
1536
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
id: `local:${taskTypeKey}`,
|
|
1148
|
-
value: taskTypeKey,
|
|
1149
|
-
details: {
|
|
1150
|
-
manifestVersion: 0,
|
|
1151
|
-
modulePath: entry.modulePath,
|
|
1152
|
-
exportName: entry.exportName,
|
|
1153
|
-
isEntry: true,
|
|
1154
|
-
},
|
|
1155
|
-
},
|
|
1156
|
-
taskTypeKey,
|
|
1537
|
+
logger.debug("Readiness checks passed", {
|
|
1538
|
+
topic: logTopic,
|
|
1539
|
+
data: { checks: report.checks.map((check) => check.name) },
|
|
1157
1540
|
});
|
|
1158
|
-
return
|
|
1541
|
+
return report;
|
|
1159
1542
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Local interval scheduler node.
|
|
1546
|
+
* Main loop: commands → heartbeat → {@link TriggerManager.tick}.
|
|
1547
|
+
*
|
|
1548
|
+
* Register runners via {@link TriggerManager.registerRunner} before {@link start}.
|
|
1549
|
+
* Does not scan Supabase trigger rows — see `trigger.api.ts` for remote admin APIs.
|
|
1550
|
+
*
|
|
1551
|
+
* Deploy **one** TriggerNode per logical scheduler — multiple processes duplicate runner work.
|
|
1552
|
+
*/
|
|
1553
|
+
class TriggerNode extends BaseNodeRuntime {
|
|
1554
|
+
requireRunners;
|
|
1555
|
+
loopIntervalMs;
|
|
1556
|
+
constructor(options) {
|
|
1557
|
+
super("triggerNode", { beforeProcessExit: options?.beforeProcessExit });
|
|
1558
|
+
this.requireRunners = options?.requireRunners ?? false;
|
|
1559
|
+
const loopIntervalMs = options?.loopIntervalMs ?? TRIGGER_LOOP_INTERVAL_MS;
|
|
1560
|
+
if (!Number.isFinite(loopIntervalMs) || loopIntervalMs < 1) {
|
|
1561
|
+
throw new Error(`[TriggerNode] loopIntervalMs must be a positive number, got ${loopIntervalMs}`);
|
|
1562
|
+
}
|
|
1563
|
+
this.loopIntervalMs = loopIntervalMs;
|
|
1167
1564
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const cacheKey = buildRemoteCacheKey(taskTypeKey, repoHash);
|
|
1171
|
-
const cached = remoteContextCache.get(cacheKey);
|
|
1172
|
-
if (cached != null) {
|
|
1173
|
-
logger.info("命中遠程 Repo 上下文緩存", { topic: "repo", data: { cacheKey } });
|
|
1174
|
-
return { context: cached, resolvedVia: "remote" };
|
|
1565
|
+
getLoopIntervalMs() {
|
|
1566
|
+
return this.loopIntervalMs;
|
|
1175
1567
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
let loaded = entryScript != null && assertScriptLoadable(entryScript.details)
|
|
1179
|
-
? await loadRepoContextFromScript({ logger, script: entryScript, taskTypeKey })
|
|
1180
|
-
: null;
|
|
1181
|
-
if (loaded == null && repoUrl !== "") {
|
|
1182
|
-
logger.info("未找到 script 行,嘗試從 Repo.details.url 加載", {
|
|
1183
|
-
topic: "repo",
|
|
1184
|
-
data: { taskTypeKey, url: repoUrl },
|
|
1185
|
-
});
|
|
1186
|
-
loaded = await loadRepoContextFromUrl({ logger, url: repoUrl, taskTypeKey });
|
|
1568
|
+
loopLoggerMinLevel() {
|
|
1569
|
+
return LogLevel.WARN;
|
|
1187
1570
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1571
|
+
async onBeforeRegisterNode(logger) {
|
|
1572
|
+
TriggerManager.closeRegistration();
|
|
1573
|
+
const runnerKeys = TriggerManager.getRunnerKeys();
|
|
1574
|
+
if (runnerKeys.length === 0) {
|
|
1575
|
+
if (this.requireRunners) {
|
|
1576
|
+
throw new Error("[TriggerNode] requireRunners is true but no runners are registered");
|
|
1577
|
+
}
|
|
1578
|
+
logger.warn("未注册任何 Trigger runner,主循環將空轉", {
|
|
1579
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1580
|
+
});
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
for (const { key, intervalMs } of TriggerManager.getRunnersBelowLoopInterval(this.loopIntervalMs)) {
|
|
1584
|
+
logger.warn("Runner interval 小於主循環 tick 間隔,實際觸發精度受限", {
|
|
1585
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1586
|
+
data: {
|
|
1587
|
+
runnerKey: key,
|
|
1588
|
+
intervalMs,
|
|
1589
|
+
loopIntervalMs: this.loopIntervalMs,
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
logger.success("Trigger 節點啟動", {
|
|
1594
|
+
topic: LOG_TOPIC_TRIGGER,
|
|
1595
|
+
data: { runnerKeys },
|
|
1193
1596
|
});
|
|
1194
|
-
return { context: null, error, failureReason: "REMOTE_REPO_LOAD_FAILED" };
|
|
1195
1597
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1598
|
+
async runLoopSteps(ctx, heartbeatOk) {
|
|
1599
|
+
if (!heartbeatOk) {
|
|
1600
|
+
const logger = createLogger({
|
|
1601
|
+
scope: createScope({
|
|
1602
|
+
module: `${this.runtimeModule}-runLoopSteps`,
|
|
1603
|
+
traceId: ctx.loopTraceId,
|
|
1604
|
+
labels: { nodeId: ctx.nodeId },
|
|
1605
|
+
}),
|
|
1606
|
+
});
|
|
1607
|
+
logger.warn("心跳失敗,本輪跳過 runner", { topic: LOG_TOPIC_NODE });
|
|
1608
|
+
return;
|
|
1206
1609
|
}
|
|
1207
|
-
|
|
1208
|
-
logger.warn(error, { topic: "repo", data: { taskTypeKey } });
|
|
1209
|
-
return { context: null, error, failureReason: "LOCAL_REPO_LOAD_FAILED" };
|
|
1610
|
+
await TriggerManager.tick(ctx);
|
|
1210
1611
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* L3 guard process TriggerNode: readiness gate, TaskNode worker spawn owner,
|
|
1616
|
+
* and {@link registerServiceGuardRunner} tick (registry slot + liveness + runtime).
|
|
1617
|
+
*
|
|
1618
|
+
* Use {@link ServiceGuardNode.create} so the guard runner is registered before `start()`.
|
|
1619
|
+
*/
|
|
1620
|
+
class ServiceGuardNode extends TriggerNode {
|
|
1621
|
+
options;
|
|
1622
|
+
constructor(options) {
|
|
1623
|
+
super({
|
|
1624
|
+
requireRunners: true,
|
|
1625
|
+
beforeProcessExit: options.beforeProcessExit,
|
|
1626
|
+
});
|
|
1627
|
+
this.options = options;
|
|
1628
|
+
}
|
|
1629
|
+
/** Register the guard runner and return a ready-to-start node. */
|
|
1630
|
+
static create(options) {
|
|
1631
|
+
registerServiceGuardRunner({
|
|
1632
|
+
serviceValue: options.serviceValue,
|
|
1633
|
+
logTopic: options.logTopic,
|
|
1634
|
+
...options.guardRunner,
|
|
1635
|
+
});
|
|
1636
|
+
return new ServiceGuardNode(options);
|
|
1637
|
+
}
|
|
1638
|
+
async onBeforeRegisterNode(nodeLogger) {
|
|
1639
|
+
await runReadinessGate({
|
|
1640
|
+
checks: this.options.readinessChecks,
|
|
1641
|
+
onReport: this.options.onReadinessReport,
|
|
1642
|
+
logger: nodeLogger,
|
|
1643
|
+
logTopic: this.options.logTopic,
|
|
1644
|
+
});
|
|
1645
|
+
await super.onBeforeRegisterNode(nodeLogger);
|
|
1646
|
+
nodeLogger.info("Spawning TaskNode worker (single spawn owner)", {
|
|
1647
|
+
topic: this.options.logTopic ?? "guard",
|
|
1648
|
+
});
|
|
1649
|
+
await this.options.spawnWorker("guard:readiness-passed");
|
|
1650
|
+
this.options.onWorkerSpawned?.();
|
|
1215
1651
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const DEFAULT_CRITICAL_SUPERVISORS = ["guard", "supervisor"];
|
|
1655
|
+
function unwrapService(created) {
|
|
1656
|
+
return typeof created === "object" && created != null && "service" in created ? created.service : created;
|
|
1657
|
+
}
|
|
1658
|
+
function createServiceHost(options) {
|
|
1659
|
+
const logger = options.logger ?? createLogger({ module: "service-host" });
|
|
1660
|
+
const logTopic = options.logTopic ?? "startup";
|
|
1661
|
+
const criticalSupervisors = options.criticalSupervisors ?? DEFAULT_CRITICAL_SUPERVISORS;
|
|
1662
|
+
let session = null;
|
|
1663
|
+
let httpServer = null;
|
|
1664
|
+
let shuttingDown = false;
|
|
1665
|
+
async function shutdown(signal, exitCode = 0) {
|
|
1666
|
+
if (shuttingDown) {
|
|
1667
|
+
return;
|
|
1220
1668
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1669
|
+
shuttingDown = true;
|
|
1670
|
+
logger.info("shutting down", { topic: logTopic, data: { signal, exitCode } });
|
|
1671
|
+
if (httpServer != null) {
|
|
1672
|
+
await httpServer.close().catch(() => undefined);
|
|
1673
|
+
httpServer = null;
|
|
1674
|
+
}
|
|
1675
|
+
if (options.onShutdown != null) {
|
|
1676
|
+
await options.onShutdown(signal).catch(() => undefined);
|
|
1677
|
+
}
|
|
1678
|
+
if (session != null) {
|
|
1679
|
+
await session.release();
|
|
1680
|
+
session = null;
|
|
1681
|
+
}
|
|
1682
|
+
process.exit(exitCode);
|
|
1226
1683
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1684
|
+
function requestShutdown(signal, exitCode = 0) {
|
|
1685
|
+
void shutdown(signal, exitCode);
|
|
1686
|
+
}
|
|
1687
|
+
function installProcessHandlers() {
|
|
1688
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1689
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1690
|
+
options.childProcesses?.setCriticalExitHandler({
|
|
1691
|
+
labels: criticalSupervisors,
|
|
1692
|
+
onCriticalExit: (label, code, signal) => {
|
|
1693
|
+
if (shuttingDown) {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
logger.critical("Critical supervisor exited — shutting down service", {
|
|
1697
|
+
topic: logTopic,
|
|
1698
|
+
data: { label, code, signal },
|
|
1699
|
+
});
|
|
1700
|
+
void shutdown("supervisor-exit", 1);
|
|
1701
|
+
},
|
|
1232
1702
|
});
|
|
1233
|
-
return { context: null, error: message, failureReason: "REMOTE_REPO_LOAD_FAILED" };
|
|
1234
1703
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1704
|
+
async function run() {
|
|
1705
|
+
installProcessHandlers();
|
|
1706
|
+
if (options.prepare != null) {
|
|
1707
|
+
await options.prepare();
|
|
1708
|
+
}
|
|
1709
|
+
try {
|
|
1710
|
+
session = await claimServiceRegistrySlot({
|
|
1711
|
+
serviceValue: options.serviceValue,
|
|
1712
|
+
createInstance: async () => unwrapService(await options.createInstance()),
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
catch (error) {
|
|
1716
|
+
if (error instanceof ServiceRegistryError && error.code === "SERVICE_SLOTS_FULL") {
|
|
1717
|
+
logger.critical("No EMPTY registry slot — refusing to start", {
|
|
1718
|
+
topic: logTopic,
|
|
1719
|
+
data: { serviceValue: options.serviceValue, code: error.code },
|
|
1720
|
+
});
|
|
1721
|
+
process.exit(1);
|
|
1722
|
+
}
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
if (options.onRegistryClaimed != null) {
|
|
1726
|
+
await options.onRegistryClaimed({ service: session.service, session });
|
|
1727
|
+
}
|
|
1728
|
+
if (options.startSupervisors != null) {
|
|
1729
|
+
await options.startSupervisors();
|
|
1730
|
+
}
|
|
1731
|
+
if (options.waitUntilReady != null) {
|
|
1732
|
+
await options.waitUntilReady();
|
|
1733
|
+
}
|
|
1734
|
+
if (options.startServer != null) {
|
|
1735
|
+
httpServer = await options.startServer();
|
|
1736
|
+
}
|
|
1240
1737
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1738
|
+
async function runWithFatalHandling() {
|
|
1739
|
+
try {
|
|
1740
|
+
await run();
|
|
1741
|
+
}
|
|
1742
|
+
catch (error) {
|
|
1743
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1744
|
+
const code = error instanceof ServiceRegistryError ? error.code : undefined;
|
|
1745
|
+
logger.error("fatal", { topic: logTopic, data: { message, code } });
|
|
1746
|
+
if (httpServer != null) {
|
|
1747
|
+
await httpServer.close().catch(() => undefined);
|
|
1748
|
+
httpServer = null;
|
|
1749
|
+
}
|
|
1750
|
+
if (options.onShutdown != null) {
|
|
1751
|
+
await options.onShutdown("fatal").catch(() => undefined);
|
|
1752
|
+
}
|
|
1753
|
+
if (session != null) {
|
|
1754
|
+
await session.release().catch(() => undefined);
|
|
1755
|
+
session = null;
|
|
1756
|
+
}
|
|
1757
|
+
process.exit(1);
|
|
1758
|
+
}
|
|
1247
1759
|
}
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1250
|
-
function unregisterLocalRepo(taskTypeKey) {
|
|
1251
|
-
localRepoRegistry.delete(taskTypeKey);
|
|
1760
|
+
return { run: runWithFatalHandling, requestShutdown };
|
|
1252
1761
|
}
|
|
1253
|
-
function hasLocalRepo(taskTypeKey) {
|
|
1254
|
-
return localRepoRegistry.has(taskTypeKey);
|
|
1255
|
-
}
|
|
1256
|
-
function getRegisteredLocalTaskTypeKeys$1() {
|
|
1257
|
-
return [...localRepoRegistry.keys()];
|
|
1258
|
-
}
|
|
1259
|
-
function clearRemoteContextCache() {
|
|
1260
|
-
remoteContextCache.clear();
|
|
1261
|
-
}
|
|
1262
|
-
const RepoManager = {
|
|
1263
|
-
getRepoContext,
|
|
1264
|
-
registerLocalRepoContext,
|
|
1265
|
-
registerLocalModule,
|
|
1266
|
-
unregisterLocalRepo,
|
|
1267
|
-
hasLocalRepo,
|
|
1268
|
-
getRegisteredLocalTaskTypeKeys: getRegisteredLocalTaskTypeKeys$1,
|
|
1269
|
-
clearRemoteContextCache,
|
|
1270
|
-
clearRepoScriptModuleCache,
|
|
1271
|
-
};
|
|
1272
1762
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
};
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
if (raw == null || typeof raw !== "object") {
|
|
1283
|
-
throw new Error(`Root config must export an object: ${configPath}`);
|
|
1763
|
+
/**
|
|
1764
|
+
* Single-process L3 entry (e.g. log-service): claim registry slot then run the service body.
|
|
1765
|
+
* Registry release is the caller's responsibility inside `run` (e.g. TriggerNode `beforeProcessExit`).
|
|
1766
|
+
*/
|
|
1767
|
+
async function runSingleProcessService(options) {
|
|
1768
|
+
const logger = options.logger ?? createLogger({ module: "single-process-service" });
|
|
1769
|
+
const logTopic = options.logTopic ?? "startup";
|
|
1770
|
+
if (options.prepare != null) {
|
|
1771
|
+
await options.prepare();
|
|
1284
1772
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1773
|
+
let session = null;
|
|
1774
|
+
try {
|
|
1775
|
+
session = await claimServiceRegistrySlot({
|
|
1776
|
+
serviceValue: options.serviceValue,
|
|
1777
|
+
createInstance: options.createInstance,
|
|
1778
|
+
});
|
|
1289
1779
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1780
|
+
catch (error) {
|
|
1781
|
+
if (error instanceof ServiceRegistryError && error.code === "SERVICE_SLOTS_FULL") {
|
|
1782
|
+
logger.critical("No EMPTY registry slot — refusing to start", {
|
|
1783
|
+
topic: logTopic,
|
|
1784
|
+
data: { serviceValue: options.serviceValue, code: error.code },
|
|
1785
|
+
});
|
|
1786
|
+
process.exit(1);
|
|
1787
|
+
}
|
|
1788
|
+
throw error;
|
|
1295
1789
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const entry = record.entry;
|
|
1299
|
-
if (typeof taskTypeKey !== "string" || taskTypeKey.trim() === "") {
|
|
1300
|
-
throw new Error(`taskTypeKey must be a non-empty string: ${configPath}`);
|
|
1790
|
+
try {
|
|
1791
|
+
await options.run({ service: session.service, session });
|
|
1301
1792
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1793
|
+
catch (error) {
|
|
1794
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1795
|
+
logger.error("fatal", { topic: logTopic, data: { message } });
|
|
1796
|
+
await session.release().catch(() => undefined);
|
|
1797
|
+
process.exit(1);
|
|
1304
1798
|
}
|
|
1305
|
-
return {
|
|
1306
|
-
taskTypeKey: taskTypeKey.trim(),
|
|
1307
|
-
entry: entry.trim(),
|
|
1308
|
-
exportName: typeof record.exportName === "string" ? record.exportName : undefined,
|
|
1309
|
-
displayName: typeof record.displayName === "string" ? record.displayName : undefined,
|
|
1310
|
-
enabled: record.enabled !== false,
|
|
1311
|
-
};
|
|
1312
1799
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1800
|
+
|
|
1801
|
+
/** Supabase `target.category` for rows that store task script payloads */
|
|
1802
|
+
const TASK_REPO_SCRIPT_CATEGORY = "script";
|
|
1803
|
+
/** Host app config directory (relative to `cwd`) — recommended for library consumers */
|
|
1804
|
+
const TASK_RUNNER_CONFIG_DIR = "config";
|
|
1805
|
+
const TASK_RUNNER_ROOT_CONFIG_FILENAME = "task.config.js";
|
|
1806
|
+
/** Default root config path: `config/task.config.js` */
|
|
1807
|
+
const TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH = `${TASK_RUNNER_CONFIG_DIR}/${TASK_RUNNER_ROOT_CONFIG_FILENAME}`;
|
|
1808
|
+
/** Legacy fallback at project root */
|
|
1809
|
+
const TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH = TASK_RUNNER_ROOT_CONFIG_FILENAME;
|
|
1810
|
+
/** Per-task config filename inside each folder under `taskDir` */
|
|
1811
|
+
const TASK_LOCAL_PACKAGE_CONFIG_FILENAME = "task.config.js";
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Directory containing a config file — anchor for paths declared **inside** that file.
|
|
1815
|
+
*/
|
|
1816
|
+
function getConfigFileDir(configFilePath) {
|
|
1817
|
+
return dirname(configFilePath);
|
|
1321
1818
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
configMtims.push(configStat.mtimeMs);
|
|
1335
|
-
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Resolve a path from a config file's directory.
|
|
1821
|
+
*
|
|
1822
|
+
* - Absolute `pathInConfig` → returned as-is
|
|
1823
|
+
* - Relative `pathInConfig` → `resolve(dirname(configFilePath), pathInConfig)`
|
|
1824
|
+
*
|
|
1825
|
+
* **Not** relative to `process.cwd()`. See `.cursor/skills/config-file-relative-paths/SKILL.md`.
|
|
1826
|
+
*/
|
|
1827
|
+
function resolvePathFromConfigFile(configFilePath, pathInConfig) {
|
|
1828
|
+
const trimmed = pathInConfig.trim();
|
|
1829
|
+
if (isAbsolute(trimmed)) {
|
|
1830
|
+
return trimmed;
|
|
1336
1831
|
}
|
|
1337
|
-
|
|
1338
|
-
return `${rootConfigPath}:${rootStat.mtimeMs}:${tasksRoot}:${tasksRootStat.mtimeMs}:${configMtims.join(",")}`;
|
|
1832
|
+
return resolve(getConfigFileDir(configFilePath), trimmed);
|
|
1339
1833
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* Resolve a path from an explicit base directory (e.g. task package dir for `entry`).
|
|
1836
|
+
* Same rules as {@link resolvePathFromConfigFile}, but base is already known.
|
|
1837
|
+
*/
|
|
1838
|
+
function resolvePathFromBaseDir(baseDir, path) {
|
|
1839
|
+
const trimmed = path.trim();
|
|
1840
|
+
if (isAbsolute(trimmed)) {
|
|
1841
|
+
return trimmed;
|
|
1348
1842
|
}
|
|
1349
|
-
return
|
|
1843
|
+
return resolve(baseDir, trimmed);
|
|
1350
1844
|
}
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
const result = {
|
|
1359
|
-
...EMPTY_RESULT,
|
|
1360
|
-
message: `No root config (tried ${TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH}, ${TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH})`,
|
|
1361
|
-
};
|
|
1362
|
-
bootstrapCache = { fingerprint: null, lastResult: result };
|
|
1363
|
-
return result;
|
|
1845
|
+
/**
|
|
1846
|
+
* Resolve a path from `cwd` — for **discovering** config files, not for fields inside them.
|
|
1847
|
+
*/
|
|
1848
|
+
function resolvePathFromCwd(cwd, path) {
|
|
1849
|
+
const trimmed = path.trim();
|
|
1850
|
+
if (isAbsolute(trimmed)) {
|
|
1851
|
+
return trimmed;
|
|
1364
1852
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
errors: [],
|
|
1369
|
-
};
|
|
1853
|
+
return resolve(cwd, trimmed);
|
|
1854
|
+
}
|
|
1855
|
+
async function pathExists(path) {
|
|
1370
1856
|
try {
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const tasksRoot = resolvePathFromConfigFile(rootConfigPath, taskDir);
|
|
1374
|
-
if (!(await pathExists(tasksRoot))) {
|
|
1375
|
-
const failed = {
|
|
1376
|
-
status: "failed",
|
|
1377
|
-
message: `taskDir does not exist: ${tasksRoot}`,
|
|
1378
|
-
...result,
|
|
1379
|
-
};
|
|
1380
|
-
bootstrapCache = { fingerprint: null, lastResult: failed };
|
|
1381
|
-
return failed;
|
|
1382
|
-
}
|
|
1383
|
-
const fingerprint = await computeConfigFingerprint(rootConfigPath, tasksRoot);
|
|
1384
|
-
const cached = canUseCachedBootstrap(fingerprint, options.forTaskTypeKey);
|
|
1385
|
-
if (cached != null) {
|
|
1386
|
-
return cached;
|
|
1387
|
-
}
|
|
1388
|
-
const entries = await readdir(tasksRoot, { withFileTypes: true });
|
|
1389
|
-
for (const entry of entries) {
|
|
1390
|
-
if (!entry.isDirectory()) {
|
|
1391
|
-
continue;
|
|
1392
|
-
}
|
|
1393
|
-
const taskPackageDir = join(tasksRoot, entry.name);
|
|
1394
|
-
const taskConfigPath = join(taskPackageDir, TASK_LOCAL_PACKAGE_CONFIG_FILENAME);
|
|
1395
|
-
if (!(await pathExists(taskConfigPath))) {
|
|
1396
|
-
result.skipped.push({ taskDir: taskPackageDir, reason: "missing task.config.js" });
|
|
1397
|
-
continue;
|
|
1398
|
-
}
|
|
1399
|
-
try {
|
|
1400
|
-
const taskRaw = await importJsConfigModule(taskConfigPath);
|
|
1401
|
-
const taskConfig = parseTaskPackageConfig(taskRaw, taskConfigPath);
|
|
1402
|
-
if (taskConfig.enabled === false) {
|
|
1403
|
-
result.skipped.push({ taskDir: taskPackageDir, reason: "disabled in task.config.js" });
|
|
1404
|
-
continue;
|
|
1405
|
-
}
|
|
1406
|
-
const entryPath = resolvePathFromBaseDir(taskPackageDir, taskConfig.entry);
|
|
1407
|
-
if (!(await pathExists(entryPath))) {
|
|
1408
|
-
throw new Error(`entry module not found: ${entryPath}`);
|
|
1409
|
-
}
|
|
1410
|
-
RepoManager.registerLocalModule(taskConfig.taskTypeKey, entryPath, taskConfig.exportName);
|
|
1411
|
-
result.registered.push(taskConfig.taskTypeKey);
|
|
1412
|
-
}
|
|
1413
|
-
catch (error) {
|
|
1414
|
-
result.errors.push({
|
|
1415
|
-
taskDir: taskPackageDir,
|
|
1416
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1417
|
-
});
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
const status = finalizeStatus(result);
|
|
1421
|
-
const finalResult = {
|
|
1422
|
-
status,
|
|
1423
|
-
message: status === "empty" ? `Root config loaded but no tasks registered under ${tasksRoot}` : undefined,
|
|
1424
|
-
...result,
|
|
1425
|
-
};
|
|
1426
|
-
bootstrapCache = { fingerprint, lastResult: finalResult };
|
|
1427
|
-
return finalResult;
|
|
1857
|
+
await access(path);
|
|
1858
|
+
return true;
|
|
1428
1859
|
}
|
|
1429
|
-
catch
|
|
1430
|
-
|
|
1431
|
-
status: "failed",
|
|
1432
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1433
|
-
...result,
|
|
1434
|
-
};
|
|
1435
|
-
bootstrapCache = { fingerprint: null, lastResult: failed };
|
|
1436
|
-
return failed;
|
|
1860
|
+
catch {
|
|
1861
|
+
return false;
|
|
1437
1862
|
}
|
|
1438
1863
|
}
|
|
1439
1864
|
/**
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
* **Never throws** for missing root config — returns `{ status: "not_configured" }`.
|
|
1443
|
-
*
|
|
1444
|
-
* Re-scans are skipped when the config fingerprint is unchanged (root config + taskDir mtimes).
|
|
1445
|
-
* Concurrent calls share a single in-flight scan (single-flight).
|
|
1446
|
-
*
|
|
1447
|
-
* Host app layout (recommended):
|
|
1448
|
-
* ```
|
|
1449
|
-
* ./config/task.config.js → { taskDir: "../tasks" }
|
|
1450
|
-
* ./tasks/my-task/task.config.js
|
|
1451
|
-
* ./tasks/my-task/index.mjs
|
|
1452
|
-
* ```
|
|
1453
|
-
*
|
|
1454
|
-
* `taskDir` / `entry` paths are relative to **their config file's directory** (see config-file-relative-paths skill).
|
|
1455
|
-
*
|
|
1456
|
-
* Legacy fallback: `./task.config.js` at project root (`taskDir: "./tasks"`).
|
|
1865
|
+
* Find the first existing file path: optional explicit path, else scan `candidatePaths` under `cwd`.
|
|
1457
1866
|
*/
|
|
1458
|
-
async function
|
|
1459
|
-
|
|
1460
|
-
|
|
1867
|
+
async function resolveFirstExistingPath(cwd, options) {
|
|
1868
|
+
const { explicitPath, candidatePaths } = options;
|
|
1869
|
+
if (explicitPath != null && explicitPath.trim() !== "") {
|
|
1870
|
+
const resolved = resolvePathFromCwd(cwd, explicitPath);
|
|
1871
|
+
return (await pathExists(resolved)) ? resolved : null;
|
|
1461
1872
|
}
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1873
|
+
for (const candidate of candidatePaths) {
|
|
1874
|
+
const resolved = join(cwd, candidate);
|
|
1875
|
+
if (await pathExists(resolved)) {
|
|
1876
|
+
return resolved;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return null;
|
|
1466
1880
|
}
|
|
1467
|
-
/**
|
|
1468
|
-
function
|
|
1469
|
-
|
|
1470
|
-
|
|
1881
|
+
/** `file://` href for Node dynamic `import()` of a local module. */
|
|
1882
|
+
function toFileImportHref(filePath) {
|
|
1883
|
+
const trimmed = filePath.trim();
|
|
1884
|
+
if (trimmed.includes("://")) {
|
|
1885
|
+
return trimmed;
|
|
1886
|
+
}
|
|
1887
|
+
return pathToFileURL(trimmed).href;
|
|
1471
1888
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1889
|
+
/** Native ESM import of a `.js` / `.mjs` config; supports `export default` or named export object. */
|
|
1890
|
+
async function importJsConfigModule(configPath) {
|
|
1891
|
+
const mod = await import(toFileImportHref(configPath));
|
|
1892
|
+
return mod.default ?? mod;
|
|
1893
|
+
}
|
|
1894
|
+
const jsConfigCache = new Map();
|
|
1895
|
+
/**
|
|
1896
|
+
* Import a JS config module, validate with a schema, cache by file mtime.
|
|
1897
|
+
* For hot-reload, pass `{ force: true }` or rely on mtime change.
|
|
1898
|
+
*/
|
|
1899
|
+
async function loadCachedJsConfigModule(options) {
|
|
1900
|
+
const { stat } = await import('node:fs/promises');
|
|
1901
|
+
const { path, schema, force } = options;
|
|
1902
|
+
const fileStat = await stat(path);
|
|
1903
|
+
const cached = jsConfigCache.get(path);
|
|
1904
|
+
if (!force && cached != null && cached.mtimeMs === fileStat.mtimeMs) {
|
|
1905
|
+
return cached.data;
|
|
1906
|
+
}
|
|
1907
|
+
const raw = await importJsConfigModule(path);
|
|
1908
|
+
const parsed = schema.safeParse(raw);
|
|
1909
|
+
if (!parsed.success) {
|
|
1910
|
+
throw new Error(`Invalid config ${path}: ${parsed.error.message}`);
|
|
1911
|
+
}
|
|
1912
|
+
jsConfigCache.set(path, { mtimeMs: fileStat.mtimeMs, data: parsed.data });
|
|
1913
|
+
return parsed.data;
|
|
1474
1914
|
}
|
|
1475
1915
|
|
|
1476
|
-
function
|
|
1477
|
-
|
|
1478
|
-
return validator(taskParams) === true;
|
|
1479
|
-
}
|
|
1480
|
-
catch (error) {
|
|
1481
|
-
logger.warn("taskParamsValidator 拋出異常", {
|
|
1482
|
-
topic: "task",
|
|
1483
|
-
data: { error: error instanceof Error ? error.message : error },
|
|
1484
|
-
});
|
|
1916
|
+
function isTaskRepoContext(value) {
|
|
1917
|
+
if (value == null || typeof value !== "object") {
|
|
1485
1918
|
return false;
|
|
1486
1919
|
}
|
|
1487
|
-
|
|
1488
|
-
function
|
|
1489
|
-
return { isValid: false, ...params };
|
|
1920
|
+
const record = value;
|
|
1921
|
+
return typeof record.taskParamsValidator === "function" && typeof record.taskFn === "function";
|
|
1490
1922
|
}
|
|
1491
1923
|
/**
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1924
|
+
* Normalize a dynamically imported module into {@link TaskRepoContext}.
|
|
1925
|
+
* Supported shapes:
|
|
1926
|
+
* - `export default { taskParamsValidator, taskFn }`
|
|
1927
|
+
* - `export { taskParamsValidator, taskFn }`
|
|
1928
|
+
* - `export default taskFn` (validator becomes `() => true`)
|
|
1496
1929
|
*/
|
|
1497
|
-
|
|
1498
|
-
if (
|
|
1499
|
-
return
|
|
1500
|
-
reason: "MISSING_PARAMS",
|
|
1501
|
-
code: ResultCode.PARAMS_NOT_VALID,
|
|
1502
|
-
message: "任務缺少 params",
|
|
1503
|
-
step: "params",
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
if (bootstrapLocal) {
|
|
1507
|
-
const bootstrap = await bootstrapLocalTasks({
|
|
1508
|
-
...bootstrapLocalOptions,
|
|
1509
|
-
forTaskTypeKey: taskTypeKey,
|
|
1510
|
-
});
|
|
1511
|
-
logger.debug("本地任務 bootstrap 完成", {
|
|
1512
|
-
topic: "task",
|
|
1513
|
-
data: {
|
|
1514
|
-
taskTypeKey,
|
|
1515
|
-
step: "local_bootstrap",
|
|
1516
|
-
status: bootstrap.status,
|
|
1517
|
-
registered: bootstrap.registered,
|
|
1518
|
-
cached: bootstrap.cached ?? false,
|
|
1519
|
-
message: bootstrap.message,
|
|
1520
|
-
},
|
|
1521
|
-
});
|
|
1930
|
+
function normalizeRepoContextModule(moduleExports, taskTypeKey, exportName) {
|
|
1931
|
+
if (moduleExports == null) {
|
|
1932
|
+
return null;
|
|
1522
1933
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
if (repoContext == null) {
|
|
1533
|
-
return invalid({
|
|
1534
|
-
reason: failureReason ?? "REMOTE_REPO_LOAD_FAILED",
|
|
1535
|
-
code: ResultCode.REPO_NOT_VALID,
|
|
1536
|
-
message: repoError ?? "無法加載 Repo 上下文",
|
|
1537
|
-
step: "repo_load",
|
|
1538
|
-
});
|
|
1934
|
+
const record = moduleExports;
|
|
1935
|
+
const namedExport = exportName != null && exportName !== "" ? record[exportName] : undefined;
|
|
1936
|
+
const defaultExport = record.default;
|
|
1937
|
+
const candidate = namedExport ?? defaultExport ?? moduleExports;
|
|
1938
|
+
let taskParamsValidator;
|
|
1939
|
+
let taskFn;
|
|
1940
|
+
if (typeof candidate === "function") {
|
|
1941
|
+
taskFn = candidate;
|
|
1942
|
+
taskParamsValidator = record.taskParamsValidator;
|
|
1539
1943
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
if (repoContext.taskFn.taskTypeKey !== taskTypeKey) {
|
|
1545
|
-
return invalid({
|
|
1546
|
-
reason: "TASK_TYPE_KEY_MISMATCH",
|
|
1547
|
-
code: ResultCode.REPO_NOT_VALID,
|
|
1548
|
-
message: `taskFn.taskTypeKey (${repoContext.taskFn.taskTypeKey}) 與 task.value (${taskTypeKey}) 不一致`,
|
|
1549
|
-
step: "task_type_key",
|
|
1550
|
-
});
|
|
1944
|
+
else if (typeof candidate === "object" && candidate != null) {
|
|
1945
|
+
const ctx = candidate;
|
|
1946
|
+
taskParamsValidator = ctx.taskParamsValidator;
|
|
1947
|
+
taskFn = ctx.taskFn;
|
|
1551
1948
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
code: ResultCode.PARAMS_NOT_VALID,
|
|
1557
|
-
message: "任務參數校驗失敗",
|
|
1558
|
-
step: "params_validation",
|
|
1559
|
-
});
|
|
1949
|
+
taskParamsValidator ??= record.taskParamsValidator;
|
|
1950
|
+
taskFn ??= record.taskFn;
|
|
1951
|
+
if (typeof taskFn !== "function") {
|
|
1952
|
+
return null;
|
|
1560
1953
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
}
|
|
1564
|
-
const TaskRepoValidation = {
|
|
1565
|
-
validate: validateTaskRepoAndParams,
|
|
1566
|
-
};
|
|
1567
|
-
|
|
1568
|
-
function logLocalBootstrap(logger, bootstrap) {
|
|
1569
|
-
switch (bootstrap.status) {
|
|
1570
|
-
case "not_configured":
|
|
1571
|
-
logger.info("未找到本地任務配置,跳過本地註冊", {
|
|
1572
|
-
topic: LOG_TOPIC_TASK,
|
|
1573
|
-
data: { message: bootstrap.message },
|
|
1574
|
-
});
|
|
1575
|
-
break;
|
|
1576
|
-
case "failed":
|
|
1577
|
-
logger.warn("本地任務註冊失敗", {
|
|
1578
|
-
topic: LOG_TOPIC_TASK,
|
|
1579
|
-
data: { message: bootstrap.message, errors: bootstrap.errors },
|
|
1580
|
-
});
|
|
1581
|
-
break;
|
|
1582
|
-
case "empty":
|
|
1583
|
-
logger.info("本地任務掃描完成,無已註冊任務", {
|
|
1584
|
-
topic: LOG_TOPIC_TASK,
|
|
1585
|
-
data: { message: bootstrap.message, skipped: bootstrap.skipped },
|
|
1586
|
-
});
|
|
1587
|
-
break;
|
|
1588
|
-
case "loaded":
|
|
1589
|
-
logger.info("本地任務註冊完成", {
|
|
1590
|
-
topic: LOG_TOPIC_TASK,
|
|
1591
|
-
data: {
|
|
1592
|
-
registered: bootstrap.registered,
|
|
1593
|
-
skipped: bootstrap.skipped,
|
|
1594
|
-
errors: bootstrap.errors,
|
|
1595
|
-
},
|
|
1596
|
-
});
|
|
1597
|
-
break;
|
|
1954
|
+
if (typeof taskParamsValidator !== "function") {
|
|
1955
|
+
taskParamsValidator = () => true;
|
|
1598
1956
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
*
|
|
1603
|
-
* - Local: {@link bootstrapLocalTasks} → registers modules in {@link RepoManager}
|
|
1604
|
-
* - Remote: {@link getScanRemoteRepoValues} with {@link TASK_REPO_USAGE}
|
|
1605
|
-
* - Merge: union of both; execution still prefers local registry in {@link RepoManager.getRepoContext}
|
|
1606
|
-
*
|
|
1607
|
-
* @throws When merged list is empty (no task types to claim).
|
|
1608
|
-
*/
|
|
1609
|
-
async function registerTasks({ logger, local: localOptions, includeRemote = true, }) {
|
|
1610
|
-
logger.debug("開始註冊任務(本地 + 遠程)", { topic: LOG_TOPIC_TASK });
|
|
1611
|
-
const local = await bootstrapLocalTasks(localOptions ?? {});
|
|
1612
|
-
logLocalBootstrap(logger, local);
|
|
1613
|
-
let remoteValues = [];
|
|
1614
|
-
if (includeRemote) {
|
|
1615
|
-
logger.debug("查詢遠程 Repo 列表", {
|
|
1616
|
-
topic: LOG_TOPIC_TASK,
|
|
1617
|
-
data: { usage: TASK_REPO_USAGE },
|
|
1618
|
-
});
|
|
1619
|
-
const { data, error: remoteError } = await getScanRemoteRepoValues({ usage: TASK_REPO_USAGE });
|
|
1620
|
-
if (remoteError) {
|
|
1621
|
-
throw new Error(remoteError.message);
|
|
1622
|
-
}
|
|
1623
|
-
remoteValues = data ?? [];
|
|
1624
|
-
logger.debug("遠程 Repo 查詢完成", {
|
|
1625
|
-
topic: LOG_TOPIC_TASK,
|
|
1626
|
-
data: { count: remoteValues.length, values: remoteValues },
|
|
1627
|
-
});
|
|
1957
|
+
const fn = taskFn;
|
|
1958
|
+
if (fn.displayName == null || fn.displayName === "") {
|
|
1959
|
+
fn.displayName = taskTypeKey;
|
|
1628
1960
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
throw new Error("任務註冊失敗:本地與遠程均無可用任務類型");
|
|
1961
|
+
if (fn.taskTypeKey == null || fn.taskTypeKey === "") {
|
|
1962
|
+
fn.taskTypeKey = taskTypeKey;
|
|
1632
1963
|
}
|
|
1633
|
-
logger.success("任務註冊完成", {
|
|
1634
|
-
topic: LOG_TOPIC_TASK,
|
|
1635
|
-
data: {
|
|
1636
|
-
availableTaskList,
|
|
1637
|
-
localRegistered: local.registered,
|
|
1638
|
-
remoteValues,
|
|
1639
|
-
},
|
|
1640
|
-
});
|
|
1641
1964
|
return {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
remote: { values: remoteValues },
|
|
1645
|
-
includeRemote,
|
|
1965
|
+
taskParamsValidator: taskParamsValidator,
|
|
1966
|
+
taskFn: fn,
|
|
1646
1967
|
};
|
|
1647
1968
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
return Object.assign(run, {
|
|
1652
|
-
displayName: taskFn.displayName,
|
|
1653
|
-
taskTypeKey: taskFn.taskTypeKey,
|
|
1654
|
-
});
|
|
1655
|
-
}
|
|
1656
|
-
/**
|
|
1657
|
-
* 將 Supabase 認領到的 Task 解析為可執行的 `ExecutableTaskFn`。
|
|
1658
|
-
*
|
|
1659
|
-
* 流程:
|
|
1660
|
-
* 1. 從 task.details 取出 `params`;`task.value` 作為 taskTypeKey(同 {@link Repo.value})
|
|
1661
|
-
* 2. {@link TaskRepoValidation.validate} — 本地註冊優先;未命中時可選遠端(`includeRemote`)
|
|
1662
|
-
* Worker 在 registry 未命中時會補掃本地(`local_bootstrap_on_miss`)
|
|
1663
|
-
* 3. bindTaskFn,返回閉包後的無參執行函數
|
|
1664
|
-
*
|
|
1665
|
-
* 失敗時返回 `{ isSuccess: false, code, message, reason, step }`(不 throw)。
|
|
1666
|
-
*/
|
|
1667
|
-
const prepareTask = async ({ logger, task, includeRemote = true, bootstrapLocalOptions, }) => {
|
|
1668
|
-
const fail = (params) => {
|
|
1669
|
-
logger.warn(params.message, {
|
|
1670
|
-
topic: LOG_TOPIC_TASK,
|
|
1671
|
-
data: {
|
|
1672
|
-
taskId: task.id,
|
|
1673
|
-
taskTypeKey: task.value,
|
|
1674
|
-
reason: params.reason,
|
|
1675
|
-
step: params.step,
|
|
1676
|
-
},
|
|
1677
|
-
});
|
|
1678
|
-
return {
|
|
1679
|
-
isSuccess: false,
|
|
1680
|
-
taskFn: null,
|
|
1681
|
-
code: params.code,
|
|
1682
|
-
message: params.message,
|
|
1683
|
-
reason: params.reason,
|
|
1684
|
-
step: params.step,
|
|
1685
|
-
};
|
|
1686
|
-
};
|
|
1687
|
-
const { id: taskId, name: taskName, value: taskTypeKey, details } = task;
|
|
1688
|
-
logger.debug(`開始準備任務 ${taskName}-${taskTypeKey}`, {
|
|
1689
|
-
topic: LOG_TOPIC_TASK,
|
|
1690
|
-
data: { taskId, taskName, taskTypeKey },
|
|
1691
|
-
});
|
|
1692
|
-
const { params: taskParams } = details;
|
|
1693
|
-
if (!getRegisteredLocalTaskTypeKeys().includes(taskTypeKey)) {
|
|
1694
|
-
const bootstrap = await bootstrapLocalTasks({
|
|
1695
|
-
...bootstrapLocalOptions,
|
|
1696
|
-
forTaskTypeKey: taskTypeKey,
|
|
1697
|
-
});
|
|
1698
|
-
logger.debug("本地 registry 未命中,補掃本地任務", {
|
|
1699
|
-
topic: LOG_TOPIC_TASK,
|
|
1700
|
-
data: {
|
|
1701
|
-
taskId,
|
|
1702
|
-
taskTypeKey,
|
|
1703
|
-
step: "local_bootstrap_on_miss",
|
|
1704
|
-
status: bootstrap.status,
|
|
1705
|
-
registered: bootstrap.registered,
|
|
1706
|
-
cached: bootstrap.cached ?? false,
|
|
1707
|
-
message: bootstrap.message,
|
|
1708
|
-
},
|
|
1709
|
-
});
|
|
1969
|
+
function pickEntryScript(scripts) {
|
|
1970
|
+
if (scripts.length === 0) {
|
|
1971
|
+
return null;
|
|
1710
1972
|
}
|
|
1711
|
-
const
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
return fail({
|
|
1719
|
-
reason: validation.reason,
|
|
1720
|
-
code: validation.code,
|
|
1721
|
-
message: validation.message,
|
|
1722
|
-
step: validation.step,
|
|
1723
|
-
});
|
|
1973
|
+
const entry = scripts.find((script) => script.details.isEntry === true);
|
|
1974
|
+
return entry ?? scripts[0];
|
|
1975
|
+
}
|
|
1976
|
+
function getScriptLoadKey(script) {
|
|
1977
|
+
const { source, modulePath, hash } = script.details;
|
|
1978
|
+
if (hash != null && hash !== "") {
|
|
1979
|
+
return hash;
|
|
1724
1980
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
topic: LOG_TOPIC_TASK,
|
|
1728
|
-
data: {
|
|
1729
|
-
taskTypeKey,
|
|
1730
|
-
taskFnDisplayName: repoContext.taskFn.displayName,
|
|
1731
|
-
step: "repo_load",
|
|
1732
|
-
},
|
|
1733
|
-
});
|
|
1734
|
-
const boundTaskFn = bindTaskFn(repoContext.taskFn, taskParams);
|
|
1735
|
-
logger.debug("任務準備完成", {
|
|
1736
|
-
topic: LOG_TOPIC_TASK,
|
|
1737
|
-
data: {
|
|
1738
|
-
taskId,
|
|
1739
|
-
taskTypeKey,
|
|
1740
|
-
displayName: boundTaskFn.displayName,
|
|
1741
|
-
step: "bind",
|
|
1742
|
-
},
|
|
1743
|
-
});
|
|
1744
|
-
return {
|
|
1745
|
-
isSuccess: true,
|
|
1746
|
-
taskFn: boundTaskFn,
|
|
1747
|
-
};
|
|
1748
|
-
};
|
|
1749
|
-
const TaskManager = {
|
|
1750
|
-
registerTasks,
|
|
1751
|
-
prepareTask,
|
|
1752
|
-
bootstrapLocalTasks,
|
|
1753
|
-
getRegisteredLocalTaskTypeKeys,
|
|
1754
|
-
clearBootstrapLocalTasksCache,
|
|
1755
|
-
};
|
|
1756
|
-
|
|
1757
|
-
/**
|
|
1758
|
-
* Task worker node: commands → heartbeat → claim & execute tasks.
|
|
1759
|
-
* Built on {@link BaseNodeRuntime}.
|
|
1760
|
-
*/
|
|
1761
|
-
class TaskNode extends BaseNodeRuntime {
|
|
1762
|
-
_availableTaskList = [];
|
|
1763
|
-
_includeRemote = true;
|
|
1764
|
-
get availableTaskList() {
|
|
1765
|
-
return [...this._availableTaskList];
|
|
1981
|
+
if (source != null && source !== "") {
|
|
1982
|
+
return `source:${source.length}:${source.slice(0, 64)}`;
|
|
1766
1983
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1984
|
+
if (modulePath != null && modulePath !== "") {
|
|
1985
|
+
return `path:${modulePath}`;
|
|
1769
1986
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1987
|
+
return script.id;
|
|
1988
|
+
}
|
|
1989
|
+
function assertScriptLoadable(details) {
|
|
1990
|
+
const hasSource = details.source != null && details.source.trim() !== "";
|
|
1991
|
+
const hasModulePath = details.modulePath != null && details.modulePath.trim() !== "";
|
|
1992
|
+
return hasSource || hasModulePath;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const importedModuleCache = new Map();
|
|
1996
|
+
async function importFromFilePath(modulePath) {
|
|
1997
|
+
return import(toFileImportHref(modulePath));
|
|
1998
|
+
}
|
|
1999
|
+
/** Resolve repo `details.url` to a value suitable for dynamic `import()`. */
|
|
2000
|
+
function resolveRepoEntryHref(url, cwd = process.cwd()) {
|
|
2001
|
+
const trimmed = url.trim();
|
|
2002
|
+
if (trimmed.includes("://")) {
|
|
2003
|
+
return trimmed;
|
|
1774
2004
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2005
|
+
return toFileImportHref(resolvePathFromCwd(cwd, trimmed));
|
|
2006
|
+
}
|
|
2007
|
+
async function importFromSource(source, cacheKey) {
|
|
2008
|
+
const hash = createHash("sha256").update(source).digest("hex").slice(0, 16);
|
|
2009
|
+
const dir = join(tmpdir(), "target-supabase-sdk", "repo-scripts");
|
|
2010
|
+
await mkdir(dir, { recursive: true });
|
|
2011
|
+
const filePath = join(dir, `${cacheKey}-${hash}.mjs`);
|
|
2012
|
+
await writeFile(filePath, source, "utf8");
|
|
2013
|
+
return importFromFilePath(filePath);
|
|
2014
|
+
}
|
|
2015
|
+
async function loadRepoContextFromScript({ logger, script, taskTypeKey, }) {
|
|
2016
|
+
const loadKey = getScriptLoadKey(script);
|
|
2017
|
+
const cacheKey = `${taskTypeKey}@${loadKey}`;
|
|
2018
|
+
const cached = importedModuleCache.get(cacheKey);
|
|
2019
|
+
if (cached != null) {
|
|
2020
|
+
logger.info("命中 Repo 脚本模块缓存", { topic: "repo", data: { cacheKey } });
|
|
2021
|
+
return cached;
|
|
1788
2022
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
logger.warn("未配置可認領任務類型,跳過本輪", { topic: LOG_TOPIC_TASK });
|
|
1796
|
-
return;
|
|
2023
|
+
const { source, modulePath, exportName } = script.details;
|
|
2024
|
+
try {
|
|
2025
|
+
let moduleExports;
|
|
2026
|
+
if (source != null && source.trim() !== "") {
|
|
2027
|
+
logger.info("从 source 动态加载 Repo 脚本", { topic: "repo", data: { scriptId: script.id } });
|
|
2028
|
+
moduleExports = await importFromSource(source, cacheKey);
|
|
1797
2029
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
const { data: task, error: claimError } = await patchClaimTask({
|
|
1803
|
-
nodeId,
|
|
1804
|
-
availableTaskList: [...availableTaskList],
|
|
1805
|
-
traceId: loopTraceId,
|
|
1806
|
-
});
|
|
1807
|
-
if (claimError) {
|
|
1808
|
-
if (isOptimisticLockResponse(claimError)) {
|
|
1809
|
-
logger.debug("認領競爭失敗,本輪無可認領任務", {
|
|
1810
|
-
topic: LOG_TOPIC_TASK,
|
|
1811
|
-
data: { availableTaskList },
|
|
1812
|
-
});
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
logger.error("認領任務失敗", {
|
|
1816
|
-
topic: LOG_TOPIC_TASK,
|
|
1817
|
-
data: {
|
|
1818
|
-
availableTaskList,
|
|
1819
|
-
error: claimError.message,
|
|
1820
|
-
code: claimError.code,
|
|
1821
|
-
},
|
|
2030
|
+
else if (modulePath != null && modulePath.trim() !== "") {
|
|
2031
|
+
logger.info("从 modulePath 动态加载 Repo 脚本", {
|
|
2032
|
+
topic: "repo",
|
|
2033
|
+
data: { scriptId: script.id, modulePath },
|
|
1822
2034
|
});
|
|
1823
|
-
|
|
2035
|
+
moduleExports = await importFromFilePath(modulePath);
|
|
1824
2036
|
}
|
|
1825
|
-
|
|
1826
|
-
logger.
|
|
1827
|
-
|
|
1828
|
-
|
|
2037
|
+
else {
|
|
2038
|
+
logger.warn("脚本缺少 source 或 modulePath", { topic: "repo", data: { scriptId: script.id } });
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
const context = normalizeRepoContextModule(moduleExports, taskTypeKey, exportName);
|
|
2042
|
+
if (context == null) {
|
|
2043
|
+
logger.warn("脚本模块未导出合法的 TaskRepoContext", {
|
|
2044
|
+
topic: "repo",
|
|
2045
|
+
data: { scriptId: script.id, exportName: exportName ?? "default" },
|
|
1829
2046
|
});
|
|
1830
|
-
return;
|
|
2047
|
+
return null;
|
|
1831
2048
|
}
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
taskLogger.success("認領任務成功,準備執行", {
|
|
1841
|
-
topic: LOG_TOPIC_TASK,
|
|
1842
|
-
data: {
|
|
1843
|
-
taskId: task.id,
|
|
1844
|
-
taskName: task.name,
|
|
1845
|
-
taskTypeKey: task.value,
|
|
1846
|
-
taskStatus: task.details.status,
|
|
1847
|
-
},
|
|
2049
|
+
const loaded = { context, contentHash: loadKey };
|
|
2050
|
+
importedModuleCache.set(cacheKey, loaded);
|
|
2051
|
+
return loaded;
|
|
2052
|
+
}
|
|
2053
|
+
catch (error) {
|
|
2054
|
+
logger.error("动态加载 Repo 脚本失败", {
|
|
2055
|
+
topic: "repo",
|
|
2056
|
+
data: { scriptId: script.id, error: error instanceof Error ? error.message : error },
|
|
1848
2057
|
});
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
async function loadRepoContextFromUrl({ logger, url, taskTypeKey, exportName, }) {
|
|
2062
|
+
const href = resolveRepoEntryHref(url);
|
|
2063
|
+
const cacheKey = `${taskTypeKey}@url:${href}`;
|
|
2064
|
+
const cached = importedModuleCache.get(cacheKey);
|
|
2065
|
+
if (cached != null) {
|
|
2066
|
+
logger.info("命中 Repo URL 模块缓存", { topic: "repo", data: { cacheKey } });
|
|
2067
|
+
return cached;
|
|
2068
|
+
}
|
|
2069
|
+
try {
|
|
2070
|
+
logger.info("从 Repo URL 动态加载脚本", { topic: "repo", data: { url, href } });
|
|
2071
|
+
const moduleExports = await import(href);
|
|
2072
|
+
const context = normalizeRepoContextModule(moduleExports, taskTypeKey, exportName);
|
|
2073
|
+
if (context == null) {
|
|
2074
|
+
logger.warn("Repo URL 模块未导出合法的 TaskRepoContext", {
|
|
2075
|
+
topic: "repo",
|
|
2076
|
+
data: { url, exportName: exportName ?? "default" },
|
|
2077
|
+
});
|
|
2078
|
+
return null;
|
|
2079
|
+
}
|
|
2080
|
+
const loaded = { context, contentHash: cacheKey };
|
|
2081
|
+
importedModuleCache.set(cacheKey, loaded);
|
|
2082
|
+
return loaded;
|
|
2083
|
+
}
|
|
2084
|
+
catch (error) {
|
|
2085
|
+
logger.error("从 Repo URL 动态加载失败", {
|
|
2086
|
+
topic: "repo",
|
|
2087
|
+
data: { url, error: error instanceof Error ? error.message : error },
|
|
1854
2088
|
});
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2089
|
+
return null;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
/** Clear in-memory imported module cache (tests / hot reload). */
|
|
2093
|
+
function clearRepoScriptModuleCache() {
|
|
2094
|
+
importedModuleCache.clear();
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
const localRepoRegistry = new Map();
|
|
2098
|
+
const remoteContextCache = new Map();
|
|
2099
|
+
function buildRemoteCacheKey(taskTypeKey, contentHash) {
|
|
2100
|
+
return `${taskTypeKey}@${contentHash}`;
|
|
2101
|
+
}
|
|
2102
|
+
async function fetchRemoteRepo(taskTypeKey) {
|
|
2103
|
+
const { data } = await getPossibleTarget({
|
|
2104
|
+
filterList: [
|
|
2105
|
+
{ field: "category", operator: "eq", value: CategoryRepo.REPO },
|
|
2106
|
+
{ field: "value", operator: "eq", value: taskTypeKey },
|
|
2107
|
+
],
|
|
2108
|
+
});
|
|
2109
|
+
return data ?? null;
|
|
2110
|
+
}
|
|
2111
|
+
async function fetchRemoteScripts(taskTypeKey) {
|
|
2112
|
+
const { data, error } = await supabase.client
|
|
2113
|
+
.from("target")
|
|
2114
|
+
.select("id, value, details")
|
|
2115
|
+
.eq("category", TASK_REPO_SCRIPT_CATEGORY)
|
|
2116
|
+
.eq("value", taskTypeKey);
|
|
2117
|
+
if (error) {
|
|
2118
|
+
throw error;
|
|
2119
|
+
}
|
|
2120
|
+
return (data ?? []);
|
|
2121
|
+
}
|
|
2122
|
+
async function loadLocalEntry(logger, entry, taskTypeKey) {
|
|
2123
|
+
if (entry.kind === "context") {
|
|
2124
|
+
return entry.context;
|
|
2125
|
+
}
|
|
2126
|
+
const loaded = await loadRepoContextFromScript({
|
|
2127
|
+
logger,
|
|
2128
|
+
script: {
|
|
2129
|
+
id: `local:${taskTypeKey}`,
|
|
2130
|
+
value: taskTypeKey,
|
|
2131
|
+
details: {
|
|
2132
|
+
manifestVersion: 0,
|
|
2133
|
+
modulePath: entry.modulePath,
|
|
2134
|
+
exportName: entry.exportName,
|
|
2135
|
+
isEntry: true,
|
|
2136
|
+
},
|
|
2137
|
+
},
|
|
2138
|
+
taskTypeKey,
|
|
2139
|
+
});
|
|
2140
|
+
return loaded?.context ?? null;
|
|
2141
|
+
}
|
|
2142
|
+
async function loadRemoteRepoContext(logger, taskTypeKey) {
|
|
2143
|
+
logger.info("從 Supabase 加載 Repo", { topic: "repo", data: { taskTypeKey } });
|
|
2144
|
+
const remoteRepo = await fetchRemoteRepo(taskTypeKey);
|
|
2145
|
+
if (remoteRepo == null) {
|
|
2146
|
+
const error = `Supabase 未找到 Repo (value=${taskTypeKey})`;
|
|
2147
|
+
logger.warn(error, { topic: "repo", data: { taskTypeKey } });
|
|
2148
|
+
return { context: null, error, failureReason: "REMOTE_REPO_NOT_FOUND" };
|
|
2149
|
+
}
|
|
2150
|
+
const repoUrl = remoteRepo.details?.url?.trim() ?? "";
|
|
2151
|
+
const repoHash = remoteRepo.details?.hash?.trim() ?? repoUrl;
|
|
2152
|
+
const cacheKey = buildRemoteCacheKey(taskTypeKey, repoHash);
|
|
2153
|
+
const cached = remoteContextCache.get(cacheKey);
|
|
2154
|
+
if (cached != null) {
|
|
2155
|
+
logger.info("命中遠程 Repo 上下文緩存", { topic: "repo", data: { cacheKey } });
|
|
2156
|
+
return { context: cached, resolvedVia: "remote" };
|
|
2157
|
+
}
|
|
2158
|
+
const scripts = await fetchRemoteScripts(taskTypeKey);
|
|
2159
|
+
const entryScript = pickEntryScript(scripts);
|
|
2160
|
+
let loaded = entryScript != null && assertScriptLoadable(entryScript.details)
|
|
2161
|
+
? await loadRepoContextFromScript({ logger, script: entryScript, taskTypeKey })
|
|
2162
|
+
: null;
|
|
2163
|
+
if (loaded == null && repoUrl !== "") {
|
|
2164
|
+
logger.info("未找到 script 行,嘗試從 Repo.details.url 加載", {
|
|
2165
|
+
topic: "repo",
|
|
2166
|
+
data: { taskTypeKey, url: repoUrl },
|
|
2167
|
+
});
|
|
2168
|
+
loaded = await loadRepoContextFromUrl({ logger, url: repoUrl, taskTypeKey });
|
|
2169
|
+
}
|
|
2170
|
+
if (loaded == null) {
|
|
2171
|
+
const error = `無法從 Repo 加載 TaskRepoContext (hasUrl=${repoUrl !== ""}, scriptCount=${scripts.length})`;
|
|
2172
|
+
logger.warn(error, {
|
|
2173
|
+
topic: "repo",
|
|
2174
|
+
data: { taskTypeKey, hasUrl: repoUrl !== "", scriptCount: scripts.length },
|
|
2175
|
+
});
|
|
2176
|
+
return { context: null, error, failureReason: "REMOTE_REPO_LOAD_FAILED" };
|
|
2177
|
+
}
|
|
2178
|
+
remoteContextCache.set(cacheKey, loaded.context);
|
|
2179
|
+
return { context: loaded.context, resolvedVia: "remote" };
|
|
2180
|
+
}
|
|
2181
|
+
const getRepoContext = async ({ logger, taskTypeKey, includeRemote = true, }) => {
|
|
2182
|
+
const localEntry = localRepoRegistry.get(taskTypeKey);
|
|
2183
|
+
if (localEntry != null) {
|
|
2184
|
+
logger.info("使用本地註冊的 Repo", { topic: "repo", data: { taskTypeKey } });
|
|
2185
|
+
const context = await loadLocalEntry(logger, localEntry, taskTypeKey);
|
|
2186
|
+
if (context != null && isTaskRepoContext(context)) {
|
|
2187
|
+
return { context: context, resolvedVia: "local" };
|
|
2188
|
+
}
|
|
2189
|
+
const error = "本地 Repo 模組加載失敗或未導出有效的 TaskRepoContext";
|
|
2190
|
+
logger.warn(error, { topic: "repo", data: { taskTypeKey } });
|
|
2191
|
+
return { context: null, error, failureReason: "LOCAL_REPO_LOAD_FAILED" };
|
|
2192
|
+
}
|
|
2193
|
+
if (!includeRemote) {
|
|
2194
|
+
const error = `本地未註冊 Repo (value=${taskTypeKey})`;
|
|
2195
|
+
logger.warn(error, { topic: "repo", data: { taskTypeKey, includeRemote: false } });
|
|
2196
|
+
return { context: null, error, failureReason: "LOCAL_REPO_NOT_FOUND" };
|
|
2197
|
+
}
|
|
2198
|
+
try {
|
|
2199
|
+
const result = await loadRemoteRepoContext(logger, taskTypeKey);
|
|
2200
|
+
if (result.context != null && isTaskRepoContext(result.context)) {
|
|
2201
|
+
return { context: result.context, resolvedVia: "remote" };
|
|
2202
|
+
}
|
|
2203
|
+
return {
|
|
2204
|
+
context: null,
|
|
2205
|
+
error: result.error ?? "遠程 Repo 上下文無效",
|
|
2206
|
+
failureReason: result.failureReason ?? "REMOTE_REPO_LOAD_FAILED",
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
catch (error) {
|
|
2210
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2211
|
+
logger.error("從 Supabase 加載 Repo 失敗", {
|
|
2212
|
+
topic: "repo",
|
|
2213
|
+
data: { taskTypeKey, error: message },
|
|
2214
|
+
});
|
|
2215
|
+
return { context: null, error: message, failureReason: "REMOTE_REPO_LOAD_FAILED" };
|
|
2216
|
+
}
|
|
2217
|
+
};
|
|
2218
|
+
/** Register an in-process TaskRepoContext (highest priority — no Supabase fetch). */
|
|
2219
|
+
function registerLocalRepoContext(taskTypeKey, context) {
|
|
2220
|
+
if (!isTaskRepoContext(context)) {
|
|
2221
|
+
throw new Error("[RepoManager] Invalid TaskRepoContext");
|
|
2222
|
+
}
|
|
2223
|
+
localRepoRegistry.set(taskTypeKey, { kind: "context", context });
|
|
2224
|
+
}
|
|
2225
|
+
/** Register a local ESM module path; loaded via dynamic import when the task runs. */
|
|
2226
|
+
function registerLocalModule(taskTypeKey, modulePath, exportName) {
|
|
2227
|
+
if (modulePath.trim() === "") {
|
|
2228
|
+
throw new Error("[RepoManager] modulePath must not be empty");
|
|
2229
|
+
}
|
|
2230
|
+
localRepoRegistry.set(taskTypeKey, { kind: "modulePath", modulePath, exportName });
|
|
2231
|
+
}
|
|
2232
|
+
function unregisterLocalRepo(taskTypeKey) {
|
|
2233
|
+
localRepoRegistry.delete(taskTypeKey);
|
|
2234
|
+
}
|
|
2235
|
+
function hasLocalRepo(taskTypeKey) {
|
|
2236
|
+
return localRepoRegistry.has(taskTypeKey);
|
|
2237
|
+
}
|
|
2238
|
+
function getRegisteredLocalTaskTypeKeys$1() {
|
|
2239
|
+
return [...localRepoRegistry.keys()];
|
|
2240
|
+
}
|
|
2241
|
+
function clearRemoteContextCache() {
|
|
2242
|
+
remoteContextCache.clear();
|
|
2243
|
+
}
|
|
2244
|
+
const RepoManager = {
|
|
2245
|
+
getRepoContext,
|
|
2246
|
+
registerLocalRepoContext,
|
|
2247
|
+
registerLocalModule,
|
|
2248
|
+
unregisterLocalRepo,
|
|
2249
|
+
hasLocalRepo,
|
|
2250
|
+
getRegisteredLocalTaskTypeKeys: getRegisteredLocalTaskTypeKeys$1,
|
|
2251
|
+
clearRemoteContextCache,
|
|
2252
|
+
clearRepoScriptModuleCache,
|
|
2253
|
+
};
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* Bind service-specific log-persist registry settings (process list, paths, timeouts).
|
|
2257
|
+
* Each L3 service exports one instance from `src/startup/log-persist.ts`.
|
|
2258
|
+
*/
|
|
2259
|
+
function createLogPersistCoordinator(options) {
|
|
2260
|
+
const { service, registryFilePath, expectedProcesses, readyTimeoutMs } = options;
|
|
2261
|
+
return {
|
|
2262
|
+
isEnabled() {
|
|
2263
|
+
return logPersistEnabledFromEnv();
|
|
2264
|
+
},
|
|
2265
|
+
getRegistryFilePath() {
|
|
2266
|
+
return registryFilePath;
|
|
2267
|
+
},
|
|
2268
|
+
registerProcess(process) {
|
|
2269
|
+
return ensureLogPersistFromEnv({
|
|
2270
|
+
process,
|
|
2271
|
+
service,
|
|
2272
|
+
registryFilePath,
|
|
1867
2273
|
});
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2274
|
+
},
|
|
2275
|
+
waitForAllProcessesReady() {
|
|
2276
|
+
return waitForLogPersistReady({
|
|
2277
|
+
registryFilePath,
|
|
2278
|
+
service,
|
|
2279
|
+
expectedProcesses: [...expectedProcesses],
|
|
2280
|
+
timeoutMs: readyTimeoutMs,
|
|
1873
2281
|
});
|
|
1874
|
-
|
|
2282
|
+
},
|
|
2283
|
+
async snapshotProcessesReady() {
|
|
2284
|
+
if (!logPersistEnabledFromEnv()) {
|
|
2285
|
+
return null;
|
|
2286
|
+
}
|
|
2287
|
+
return snapshotLogPersistReady({
|
|
2288
|
+
registryFilePath,
|
|
2289
|
+
expectedProcesses: [...expectedProcesses],
|
|
2290
|
+
});
|
|
2291
|
+
},
|
|
2292
|
+
getPersistStats() {
|
|
2293
|
+
if (!logPersistEnabledFromEnv()) {
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
return getLogPersistStats();
|
|
2297
|
+
},
|
|
2298
|
+
async shutdownLogPersist() {
|
|
2299
|
+
if (!logPersistEnabledFromEnv()) {
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
await disableLogPersist();
|
|
2303
|
+
},
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const EMPTY_RESULT = {
|
|
2308
|
+
status: "not_configured",
|
|
2309
|
+
registered: [],
|
|
2310
|
+
skipped: [],
|
|
2311
|
+
errors: [],
|
|
2312
|
+
};
|
|
2313
|
+
let bootstrapCache = { fingerprint: null, lastResult: null };
|
|
2314
|
+
let bootstrapInFlight = null;
|
|
2315
|
+
function parseRootConfig(raw, configPath) {
|
|
2316
|
+
if (raw == null || typeof raw !== "object") {
|
|
2317
|
+
throw new Error(`Root config must export an object: ${configPath}`);
|
|
2318
|
+
}
|
|
2319
|
+
const record = raw;
|
|
2320
|
+
const taskDir = record.taskDir;
|
|
2321
|
+
if (typeof taskDir !== "string" || taskDir.trim() === "") {
|
|
2322
|
+
throw new Error(`Root config.taskDir must be a non-empty string: ${configPath}`);
|
|
2323
|
+
}
|
|
2324
|
+
return { taskDir: taskDir.trim() };
|
|
2325
|
+
}
|
|
2326
|
+
function parseTaskPackageConfig(raw, configPath) {
|
|
2327
|
+
if (raw == null || typeof raw !== "object") {
|
|
2328
|
+
throw new Error(`Task config must export an object: ${configPath}`);
|
|
2329
|
+
}
|
|
2330
|
+
const record = raw;
|
|
2331
|
+
const taskTypeKey = record.taskTypeKey;
|
|
2332
|
+
const entry = record.entry;
|
|
2333
|
+
if (typeof taskTypeKey !== "string" || taskTypeKey.trim() === "") {
|
|
2334
|
+
throw new Error(`taskTypeKey must be a non-empty string: ${configPath}`);
|
|
2335
|
+
}
|
|
2336
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
2337
|
+
throw new Error(`entry must be a non-empty string: ${configPath}`);
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
taskTypeKey: taskTypeKey.trim(),
|
|
2341
|
+
entry: entry.trim(),
|
|
2342
|
+
exportName: typeof record.exportName === "string" ? record.exportName : undefined,
|
|
2343
|
+
displayName: typeof record.displayName === "string" ? record.displayName : undefined,
|
|
2344
|
+
enabled: record.enabled !== false,
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
function finalizeStatus(result) {
|
|
2348
|
+
if (result.errors.length > 0 && result.registered.length === 0) {
|
|
2349
|
+
return "failed";
|
|
2350
|
+
}
|
|
2351
|
+
if (result.registered.length === 0) {
|
|
2352
|
+
return "empty";
|
|
2353
|
+
}
|
|
2354
|
+
return "loaded";
|
|
2355
|
+
}
|
|
2356
|
+
async function computeConfigFingerprint(rootConfigPath, tasksRoot) {
|
|
2357
|
+
const rootStat = await stat(rootConfigPath);
|
|
2358
|
+
const tasksRootStat = await stat(tasksRoot);
|
|
2359
|
+
const entries = await readdir(tasksRoot, { withFileTypes: true });
|
|
2360
|
+
const configMtims = [];
|
|
2361
|
+
for (const entry of entries) {
|
|
2362
|
+
if (!entry.isDirectory()) {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const configPath = join(tasksRoot, entry.name, TASK_LOCAL_PACKAGE_CONFIG_FILENAME);
|
|
2366
|
+
if (await pathExists(configPath)) {
|
|
2367
|
+
const configStat = await stat(configPath);
|
|
2368
|
+
configMtims.push(configStat.mtimeMs);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
configMtims.sort((a, b) => a - b);
|
|
2372
|
+
return `${rootConfigPath}:${rootStat.mtimeMs}:${tasksRoot}:${tasksRootStat.mtimeMs}:${configMtims.join(",")}`;
|
|
2373
|
+
}
|
|
2374
|
+
function canUseCachedBootstrap(fingerprint, forTaskTypeKey) {
|
|
2375
|
+
if (bootstrapCache.fingerprint !== fingerprint || bootstrapCache.lastResult == null) {
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
if (forTaskTypeKey != null && forTaskTypeKey !== "") {
|
|
2379
|
+
if (!RepoManager.hasLocalRepo(forTaskTypeKey)) {
|
|
2380
|
+
return null;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return { ...bootstrapCache.lastResult, cached: true };
|
|
2384
|
+
}
|
|
2385
|
+
async function runBootstrapScan(options) {
|
|
2386
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2387
|
+
const rootConfigPath = await resolveFirstExistingPath(cwd, {
|
|
2388
|
+
explicitPath: options.rootConfigPath,
|
|
2389
|
+
candidatePaths: [TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH, TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH],
|
|
2390
|
+
});
|
|
2391
|
+
if (rootConfigPath == null) {
|
|
2392
|
+
const result = {
|
|
2393
|
+
...EMPTY_RESULT,
|
|
2394
|
+
message: `No root config (tried ${TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH}, ${TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH})`,
|
|
2395
|
+
};
|
|
2396
|
+
bootstrapCache = { fingerprint: null, lastResult: result };
|
|
2397
|
+
return result;
|
|
2398
|
+
}
|
|
2399
|
+
const result = {
|
|
2400
|
+
registered: [],
|
|
2401
|
+
skipped: [],
|
|
2402
|
+
errors: [],
|
|
2403
|
+
};
|
|
2404
|
+
try {
|
|
2405
|
+
const rootRaw = await importJsConfigModule(rootConfigPath);
|
|
2406
|
+
const { taskDir } = parseRootConfig(rootRaw, rootConfigPath);
|
|
2407
|
+
const tasksRoot = resolvePathFromConfigFile(rootConfigPath, taskDir);
|
|
2408
|
+
if (!(await pathExists(tasksRoot))) {
|
|
2409
|
+
const failed = {
|
|
2410
|
+
status: "failed",
|
|
2411
|
+
message: `taskDir does not exist: ${tasksRoot}`,
|
|
2412
|
+
...result,
|
|
2413
|
+
};
|
|
2414
|
+
bootstrapCache = { fingerprint: null, lastResult: failed };
|
|
2415
|
+
return failed;
|
|
1875
2416
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
2417
|
+
const fingerprint = await computeConfigFingerprint(rootConfigPath, tasksRoot);
|
|
2418
|
+
const cached = canUseCachedBootstrap(fingerprint, options.forTaskTypeKey);
|
|
2419
|
+
if (cached != null) {
|
|
2420
|
+
return cached;
|
|
2421
|
+
}
|
|
2422
|
+
const entries = await readdir(tasksRoot, { withFileTypes: true });
|
|
2423
|
+
for (const entry of entries) {
|
|
2424
|
+
if (!entry.isDirectory()) {
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
const taskPackageDir = join(tasksRoot, entry.name);
|
|
2428
|
+
const taskConfigPath = join(taskPackageDir, TASK_LOCAL_PACKAGE_CONFIG_FILENAME);
|
|
2429
|
+
if (!(await pathExists(taskConfigPath))) {
|
|
2430
|
+
result.skipped.push({ taskDir: taskPackageDir, reason: "missing task.config.js" });
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
try {
|
|
2434
|
+
const taskRaw = await importJsConfigModule(taskConfigPath);
|
|
2435
|
+
const taskConfig = parseTaskPackageConfig(taskRaw, taskConfigPath);
|
|
2436
|
+
if (taskConfig.enabled === false) {
|
|
2437
|
+
result.skipped.push({ taskDir: taskPackageDir, reason: "disabled in task.config.js" });
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
const entryPath = resolvePathFromBaseDir(taskPackageDir, taskConfig.entry);
|
|
2441
|
+
if (!(await pathExists(entryPath))) {
|
|
2442
|
+
throw new Error(`entry module not found: ${entryPath}`);
|
|
2443
|
+
}
|
|
2444
|
+
RepoManager.registerLocalModule(taskConfig.taskTypeKey, entryPath, taskConfig.exportName);
|
|
2445
|
+
result.registered.push(taskConfig.taskTypeKey);
|
|
2446
|
+
}
|
|
2447
|
+
catch (error) {
|
|
2448
|
+
result.errors.push({
|
|
2449
|
+
taskDir: taskPackageDir,
|
|
2450
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
const status = finalizeStatus(result);
|
|
2455
|
+
const finalResult = {
|
|
2456
|
+
status,
|
|
2457
|
+
message: status === "empty" ? `Root config loaded but no tasks registered under ${tasksRoot}` : undefined,
|
|
2458
|
+
...result,
|
|
2459
|
+
};
|
|
2460
|
+
bootstrapCache = { fingerprint, lastResult: finalResult };
|
|
2461
|
+
return finalResult;
|
|
2462
|
+
}
|
|
2463
|
+
catch (error) {
|
|
2464
|
+
const failed = {
|
|
2465
|
+
status: "failed",
|
|
2466
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2467
|
+
...result,
|
|
2468
|
+
};
|
|
2469
|
+
bootstrapCache = { fingerprint: null, lastResult: failed };
|
|
2470
|
+
return failed;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Discover local task packages from config files and register them with {@link RepoManager}.
|
|
2475
|
+
*
|
|
2476
|
+
* **Never throws** for missing root config — returns `{ status: "not_configured" }`.
|
|
2477
|
+
*
|
|
2478
|
+
* Re-scans are skipped when the config fingerprint is unchanged (root config + taskDir mtimes).
|
|
2479
|
+
* Concurrent calls share a single in-flight scan (single-flight).
|
|
2480
|
+
*
|
|
2481
|
+
* Host app layout (recommended):
|
|
2482
|
+
* ```
|
|
2483
|
+
* ./config/task.config.js → { taskDir: "../tasks" }
|
|
2484
|
+
* ./tasks/my-task/task.config.js
|
|
2485
|
+
* ./tasks/my-task/index.mjs
|
|
2486
|
+
* ```
|
|
2487
|
+
*
|
|
2488
|
+
* `taskDir` / `entry` paths are relative to **their config file's directory** (see config-file-relative-paths skill).
|
|
2489
|
+
*
|
|
2490
|
+
* Legacy fallback: `./task.config.js` at project root (`taskDir: "./tasks"`).
|
|
2491
|
+
*/
|
|
2492
|
+
async function bootstrapLocalTasks(options = {}) {
|
|
2493
|
+
if (bootstrapInFlight != null) {
|
|
2494
|
+
return bootstrapInFlight;
|
|
2495
|
+
}
|
|
2496
|
+
bootstrapInFlight = runBootstrapScan(options).finally(() => {
|
|
2497
|
+
bootstrapInFlight = null;
|
|
2498
|
+
});
|
|
2499
|
+
return bootstrapInFlight;
|
|
2500
|
+
}
|
|
2501
|
+
/** Clear fingerprint cache (tests / hot-reload tooling). */
|
|
2502
|
+
function clearBootstrapLocalTasksCache() {
|
|
2503
|
+
bootstrapCache = { fingerprint: null, lastResult: null };
|
|
2504
|
+
bootstrapInFlight = null;
|
|
2505
|
+
}
|
|
2506
|
+
function getRegisteredLocalTaskTypeKeys() {
|
|
2507
|
+
return RepoManager.getRegisteredLocalTaskTypeKeys();
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function validateTaskParams(validator, taskParams, logger) {
|
|
2511
|
+
try {
|
|
2512
|
+
return validator(taskParams) === true;
|
|
2513
|
+
}
|
|
2514
|
+
catch (error) {
|
|
2515
|
+
logger.warn("taskParamsValidator 拋出異常", {
|
|
2516
|
+
topic: "task",
|
|
2517
|
+
data: { error: error instanceof Error ? error.message : error },
|
|
2518
|
+
});
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
function invalid(params) {
|
|
2523
|
+
return { isValid: false, ...params };
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Resolve task Repo context and validate `params` via `taskParamsValidator`.
|
|
2527
|
+
*
|
|
2528
|
+
* Repo resolution uses {@link RepoManager.getRepoContext}:
|
|
2529
|
+
* local registry first; when no local entry and `includeRemote`, loads from Supabase.
|
|
2530
|
+
*/
|
|
2531
|
+
async function validateTaskRepoAndParams({ logger, taskTypeKey, params, bootstrapLocal = false, bootstrapLocalOptions, includeRemote = true, }) {
|
|
2532
|
+
if (params == null) {
|
|
2533
|
+
return invalid({
|
|
2534
|
+
reason: "MISSING_PARAMS",
|
|
2535
|
+
code: ResultCode.PARAMS_NOT_VALID,
|
|
2536
|
+
message: "任務缺少 params",
|
|
2537
|
+
step: "params",
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
if (bootstrapLocal) {
|
|
2541
|
+
const bootstrap = await bootstrapLocalTasks({
|
|
2542
|
+
...bootstrapLocalOptions,
|
|
2543
|
+
forTaskTypeKey: taskTypeKey,
|
|
2544
|
+
});
|
|
2545
|
+
logger.debug("本地任務 bootstrap 完成", {
|
|
2546
|
+
topic: "task",
|
|
1878
2547
|
data: {
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2548
|
+
taskTypeKey,
|
|
2549
|
+
step: "local_bootstrap",
|
|
2550
|
+
status: bootstrap.status,
|
|
2551
|
+
registered: bootstrap.registered,
|
|
2552
|
+
cached: bootstrap.cached ?? false,
|
|
2553
|
+
message: bootstrap.message,
|
|
1883
2554
|
},
|
|
1884
2555
|
});
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2556
|
+
}
|
|
2557
|
+
logger.debug("開始解析 Repo 上下文", {
|
|
2558
|
+
topic: "task",
|
|
2559
|
+
data: { taskTypeKey, step: "repo_load", includeRemote },
|
|
2560
|
+
});
|
|
2561
|
+
const { context: repoContext, error: repoError, failureReason, resolvedVia, } = await RepoManager.getRepoContext({
|
|
2562
|
+
logger,
|
|
2563
|
+
taskTypeKey,
|
|
2564
|
+
includeRemote,
|
|
2565
|
+
});
|
|
2566
|
+
if (repoContext == null) {
|
|
2567
|
+
return invalid({
|
|
2568
|
+
reason: failureReason ?? "REMOTE_REPO_LOAD_FAILED",
|
|
2569
|
+
code: ResultCode.REPO_NOT_VALID,
|
|
2570
|
+
message: repoError ?? "無法加載 Repo 上下文",
|
|
2571
|
+
step: "repo_load",
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
logger.debug("Repo 上下文解析完成", {
|
|
2575
|
+
topic: "task",
|
|
2576
|
+
data: { taskTypeKey, step: "repo_load", resolvedVia },
|
|
2577
|
+
});
|
|
2578
|
+
if (repoContext.taskFn.taskTypeKey !== taskTypeKey) {
|
|
2579
|
+
return invalid({
|
|
2580
|
+
reason: "TASK_TYPE_KEY_MISMATCH",
|
|
2581
|
+
code: ResultCode.REPO_NOT_VALID,
|
|
2582
|
+
message: `taskFn.taskTypeKey (${repoContext.taskFn.taskTypeKey}) 與 task.value (${taskTypeKey}) 不一致`,
|
|
2583
|
+
step: "task_type_key",
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
logger.debug("開始校驗任務參數", { topic: "task", data: { taskTypeKey, step: "params_validation" } });
|
|
2587
|
+
if (!validateTaskParams(repoContext.taskParamsValidator, params, logger)) {
|
|
2588
|
+
return invalid({
|
|
2589
|
+
reason: "PARAMS_VALIDATION_FAILED",
|
|
2590
|
+
code: ResultCode.PARAMS_NOT_VALID,
|
|
2591
|
+
message: "任務參數校驗失敗",
|
|
2592
|
+
step: "params_validation",
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
logger.debug("任務參數校驗通過", { topic: "task", data: { taskTypeKey, step: "params_validation" } });
|
|
2596
|
+
return { isValid: true, repoContext };
|
|
2597
|
+
}
|
|
2598
|
+
const TaskRepoValidation = {
|
|
2599
|
+
validate: validateTaskRepoAndParams,
|
|
2600
|
+
};
|
|
2601
|
+
|
|
2602
|
+
function logLocalBootstrap(logger, bootstrap) {
|
|
2603
|
+
switch (bootstrap.status) {
|
|
2604
|
+
case "not_configured":
|
|
2605
|
+
logger.info("未找到本地任務配置,跳過本地註冊", {
|
|
2606
|
+
topic: LOG_TOPIC_TASK,
|
|
2607
|
+
data: { message: bootstrap.message },
|
|
2608
|
+
});
|
|
2609
|
+
break;
|
|
2610
|
+
case "failed":
|
|
2611
|
+
logger.warn("本地任務註冊失敗", {
|
|
2612
|
+
topic: LOG_TOPIC_TASK,
|
|
2613
|
+
data: { message: bootstrap.message, errors: bootstrap.errors },
|
|
2614
|
+
});
|
|
2615
|
+
break;
|
|
2616
|
+
case "empty":
|
|
2617
|
+
logger.info("本地任務掃描完成,無已註冊任務", {
|
|
2618
|
+
topic: LOG_TOPIC_TASK,
|
|
2619
|
+
data: { message: bootstrap.message, skipped: bootstrap.skipped },
|
|
2620
|
+
});
|
|
2621
|
+
break;
|
|
2622
|
+
case "loaded":
|
|
2623
|
+
logger.info("本地任務註冊完成", {
|
|
1888
2624
|
topic: LOG_TOPIC_TASK,
|
|
1889
2625
|
data: {
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
displayName: taskFn.displayName,
|
|
1894
|
-
error: getErrorMessage(error),
|
|
2626
|
+
registered: bootstrap.registered,
|
|
2627
|
+
skipped: bootstrap.skipped,
|
|
2628
|
+
errors: bootstrap.errors,
|
|
1895
2629
|
},
|
|
1896
2630
|
});
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2635
|
+
* Worker startup: discover local task packages + remote Repo rows, merge into `availableTaskList`.
|
|
2636
|
+
*
|
|
2637
|
+
* - Local: {@link bootstrapLocalTasks} → registers modules in {@link RepoManager}
|
|
2638
|
+
* - Remote: {@link getScanRemoteRepoValues} with {@link TASK_REPO_USAGE}
|
|
2639
|
+
* - Merge: union of both; execution still prefers local registry in {@link RepoManager.getRepoContext}
|
|
2640
|
+
*
|
|
2641
|
+
* @throws When merged list is empty (no task types to claim).
|
|
2642
|
+
*/
|
|
2643
|
+
async function registerTasks({ logger, local: localOptions, includeRemote = true, }) {
|
|
2644
|
+
logger.debug("開始註冊任務(本地 + 遠程)", { topic: LOG_TOPIC_TASK });
|
|
2645
|
+
const local = await bootstrapLocalTasks(localOptions ?? {});
|
|
2646
|
+
logLocalBootstrap(logger, local);
|
|
2647
|
+
let remoteValues = [];
|
|
2648
|
+
if (includeRemote) {
|
|
2649
|
+
logger.debug("查詢遠程 Repo 列表", {
|
|
1905
2650
|
topic: LOG_TOPIC_TASK,
|
|
1906
|
-
data: {
|
|
2651
|
+
data: { usage: TASK_REPO_USAGE },
|
|
1907
2652
|
});
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
result = await taskFn();
|
|
1912
|
-
}
|
|
1913
|
-
catch (error) {
|
|
1914
|
-
await onExecuteAbort(toError(error));
|
|
1915
|
-
return;
|
|
1916
|
-
}
|
|
1917
|
-
if (result == null) {
|
|
1918
|
-
await onExecuteAbort(new Error("任務執行結果為空"));
|
|
1919
|
-
return;
|
|
1920
|
-
}
|
|
1921
|
-
const { isSuccess: isTaskSuccess, cost, extra } = result ?? { isSuccess: false, cost: 0, extra: null };
|
|
1922
|
-
if (isTaskSuccess) {
|
|
1923
|
-
await this.finalizeTaskRun("success", {
|
|
1924
|
-
logger: executeLogger,
|
|
1925
|
-
taskId: task.id,
|
|
1926
|
-
taskTypeKey: task.value,
|
|
1927
|
-
nodeId,
|
|
1928
|
-
cost,
|
|
1929
|
-
extra,
|
|
1930
|
-
traceId: taskTraceId,
|
|
1931
|
-
});
|
|
1932
|
-
}
|
|
1933
|
-
else {
|
|
1934
|
-
await this.finalizeTaskRun("failure", {
|
|
1935
|
-
logger: executeLogger,
|
|
1936
|
-
taskId: task.id,
|
|
1937
|
-
taskTypeKey: task.value,
|
|
1938
|
-
nodeId,
|
|
1939
|
-
cost,
|
|
1940
|
-
extra,
|
|
1941
|
-
traceId: taskTraceId,
|
|
1942
|
-
});
|
|
2653
|
+
const { data, error: remoteError } = await getScanRemoteRepoValues({ usage: TASK_REPO_USAGE });
|
|
2654
|
+
if (remoteError) {
|
|
2655
|
+
throw new Error(remoteError.message);
|
|
1943
2656
|
}
|
|
1944
|
-
|
|
1945
|
-
|
|
2657
|
+
remoteValues = data ?? [];
|
|
2658
|
+
logger.debug("遠程 Repo 查詢完成", {
|
|
1946
2659
|
topic: LOG_TOPIC_TASK,
|
|
1947
|
-
data: {
|
|
1948
|
-
taskId: task.id,
|
|
1949
|
-
taskName: task.name,
|
|
1950
|
-
taskTypeKey: task.value,
|
|
1951
|
-
displayName: taskFn.displayName,
|
|
1952
|
-
durationMs: duration,
|
|
1953
|
-
},
|
|
1954
|
-
});
|
|
1955
|
-
}
|
|
1956
|
-
async abortTaskRun(params) {
|
|
1957
|
-
const { logger, taskId, nodeId, traceId, extra } = params;
|
|
1958
|
-
const logData = { taskId, nodeId };
|
|
1959
|
-
const extraValue = typeof extra === "string" ? extra : undefined;
|
|
1960
|
-
const { error } = await patchChangeTaskStatus({
|
|
1961
|
-
id: taskId,
|
|
1962
|
-
action: TaskStatusAction.RESET,
|
|
1963
|
-
nodeId,
|
|
1964
|
-
extra: extraValue,
|
|
1965
|
-
traceId,
|
|
2660
|
+
data: { count: remoteValues.length, values: remoteValues },
|
|
1966
2661
|
});
|
|
1967
|
-
if (error) {
|
|
1968
|
-
logger.error(error.message, { topic: LOG_TOPIC_TASK, data: logData });
|
|
1969
|
-
return false;
|
|
1970
|
-
}
|
|
1971
|
-
logger.info("任務已中止並 RESET 為 OPEN", { topic: LOG_TOPIC_TASK, data: logData });
|
|
1972
|
-
return true;
|
|
1973
2662
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
const logData = {
|
|
1978
|
-
taskTypeKey,
|
|
1979
|
-
taskId,
|
|
1980
|
-
nodeId,
|
|
1981
|
-
...(extra != null && extra !== "" ? { extra } : {}),
|
|
1982
|
-
};
|
|
1983
|
-
const extraValue = typeof extra === "string" ? extra : undefined;
|
|
1984
|
-
const { error: changeTaskStatusError } = outcome === "success"
|
|
1985
|
-
? await patchChangeTaskStatus({
|
|
1986
|
-
id: taskId,
|
|
1987
|
-
action: TaskStatusAction.FINISH,
|
|
1988
|
-
nodeId,
|
|
1989
|
-
cost,
|
|
1990
|
-
extra: extraValue,
|
|
1991
|
-
traceId,
|
|
1992
|
-
})
|
|
1993
|
-
: await patchChangeTaskStatus({
|
|
1994
|
-
id: taskId,
|
|
1995
|
-
action: TaskStatusAction.RESET,
|
|
1996
|
-
nodeId,
|
|
1997
|
-
extra: extraValue,
|
|
1998
|
-
traceId,
|
|
1999
|
-
});
|
|
2000
|
-
if (changeTaskStatusError) {
|
|
2001
|
-
const upperMessage = `${outcomePrefix}但是更新 TaskStatus 失敗,產生孤兒 Task,請儘快排查問題`;
|
|
2002
|
-
logger.critical(upperMessage, { topic: LOG_TOPIC_TASK, data: logData });
|
|
2003
|
-
// TODO: 需要處理孤兒 Task
|
|
2004
|
-
throw new Error(upperMessage);
|
|
2005
|
-
}
|
|
2006
|
-
logger.info("更新 TaskStatus 成功", { topic: LOG_TOPIC_TASK, data: logData });
|
|
2007
|
-
if (outcome === "success") {
|
|
2008
|
-
logger.success("任務執行結束,業務邏輯成功", { topic: LOG_TOPIC_TASK, data: logData });
|
|
2009
|
-
}
|
|
2010
|
-
else {
|
|
2011
|
-
logger.warn("任務執行結束,業務邏輯失敗", { topic: LOG_TOPIC_TASK, data: logData });
|
|
2012
|
-
}
|
|
2663
|
+
const availableTaskList = [...new Set([...local.registered, ...remoteValues])];
|
|
2664
|
+
if (availableTaskList.length === 0) {
|
|
2665
|
+
throw new Error("任務註冊失敗:本地與遠程均無可用任務類型");
|
|
2013
2666
|
}
|
|
2667
|
+
logger.success("任務註冊完成", {
|
|
2668
|
+
topic: LOG_TOPIC_TASK,
|
|
2669
|
+
data: {
|
|
2670
|
+
availableTaskList,
|
|
2671
|
+
localRegistered: local.registered,
|
|
2672
|
+
remoteValues,
|
|
2673
|
+
},
|
|
2674
|
+
});
|
|
2675
|
+
return {
|
|
2676
|
+
availableTaskList,
|
|
2677
|
+
local,
|
|
2678
|
+
remote: { values: remoteValues },
|
|
2679
|
+
includeRemote,
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
/** 将 taskFn 与 params 闭包绑定,供 TaskNode 无参调用 `await taskFn()` */
|
|
2683
|
+
function bindTaskFn(taskFn, taskParams, ctx) {
|
|
2684
|
+
const run = async () => taskFn(taskParams, ctx);
|
|
2685
|
+
return Object.assign(run, {
|
|
2686
|
+
displayName: taskFn.displayName,
|
|
2687
|
+
taskTypeKey: taskFn.taskTypeKey,
|
|
2688
|
+
});
|
|
2014
2689
|
}
|
|
2015
|
-
|
|
2016
2690
|
/**
|
|
2017
|
-
*
|
|
2018
|
-
* **Node entry only** — requires local Repo bootstrap and {@link TaskRepoValidation}.
|
|
2691
|
+
* 將 Supabase 認領到的 Task 解析為可執行的 `ExecutableTaskFn`。
|
|
2019
2692
|
*
|
|
2020
|
-
*
|
|
2693
|
+
* 流程:
|
|
2694
|
+
* 1. 從 task.details 取出 `params`;`task.value` 作為 taskTypeKey(同 {@link Repo.value})
|
|
2695
|
+
* 2. {@link TaskRepoValidation.validate} — 本地註冊優先;未命中時可選遠端(`includeRemote`)
|
|
2696
|
+
* Worker 在 registry 未命中時會補掃本地(`local_bootstrap_on_miss`)
|
|
2697
|
+
* 3. bindTaskFn,返回閉包後的無參執行函數
|
|
2698
|
+
*
|
|
2699
|
+
* 失敗時返回 `{ isSuccess: false, code, message, reason, step }`(不 throw)。
|
|
2021
2700
|
*/
|
|
2022
|
-
const
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
traceId: taskTraceId,
|
|
2027
|
-
traceParentId: traceParentId ?? null,
|
|
2028
|
-
});
|
|
2029
|
-
const validation = await TaskRepoValidation.validate({
|
|
2030
|
-
logger,
|
|
2031
|
-
taskTypeKey: value,
|
|
2032
|
-
params,
|
|
2033
|
-
bootstrapLocal: true,
|
|
2034
|
-
});
|
|
2035
|
-
if (!validation.isValid) {
|
|
2036
|
-
logger.warn(validation.message, {
|
|
2037
|
-
topic: "task",
|
|
2701
|
+
const prepareTask = async ({ logger, task, includeRemote = true, bootstrapLocalOptions, }) => {
|
|
2702
|
+
const fail = (params) => {
|
|
2703
|
+
logger.warn(params.message, {
|
|
2704
|
+
topic: LOG_TOPIC_TASK,
|
|
2038
2705
|
data: {
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2706
|
+
taskId: task.id,
|
|
2707
|
+
taskTypeKey: task.value,
|
|
2708
|
+
reason: params.reason,
|
|
2709
|
+
step: params.step,
|
|
2042
2710
|
},
|
|
2043
2711
|
});
|
|
2044
|
-
return
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
})(CategoryTrigger || (CategoryTrigger = {}));
|
|
2053
|
-
var TriggerStatus;
|
|
2054
|
-
(function (TriggerStatus) {
|
|
2055
|
-
TriggerStatus["ENABLED"] = "ENABLED";
|
|
2056
|
-
TriggerStatus["DISABLED"] = "DISABLED";
|
|
2057
|
-
TriggerStatus["PAUSED"] = "PAUSED";
|
|
2058
|
-
})(TriggerStatus || (TriggerStatus = {}));
|
|
2059
|
-
|
|
2060
|
-
const TRIGGER_STATUS_FIELD = "details->>status";
|
|
2061
|
-
const traceIdSchema = z.string().trim().min(1).optional();
|
|
2062
|
-
const dailyScheduleSchema = z.object({
|
|
2063
|
-
kind: z.literal("daily"),
|
|
2064
|
-
hour: z.number().int().min(0).max(23),
|
|
2065
|
-
minute: z.number().int().min(0).max(59),
|
|
2066
|
-
});
|
|
2067
|
-
const postTaskActionSchema = z.object({
|
|
2068
|
-
kind: z.literal("post_task"),
|
|
2069
|
-
taskTypeKey: z.string().trim().min(1),
|
|
2070
|
-
taskParams: z.unknown(),
|
|
2071
|
-
taskName: z.string().trim().min(1).optional(),
|
|
2072
|
-
taskStatus: z.enum([TaskStatus.OPEN, TaskStatus.TODO]).optional(),
|
|
2073
|
-
});
|
|
2074
|
-
const postTriggerSchema = z.object({
|
|
2075
|
-
name: z.string().trim().min(1),
|
|
2076
|
-
value: z.string().trim().min(1),
|
|
2077
|
-
status: z.nativeEnum(TriggerStatus).optional().default(TriggerStatus.ENABLED),
|
|
2078
|
-
schedule: dailyScheduleSchema,
|
|
2079
|
-
action: postTaskActionSchema,
|
|
2080
|
-
tagList: z.array(z.string()).optional().default([]),
|
|
2081
|
-
traceId: traceIdSchema,
|
|
2082
|
-
});
|
|
2083
|
-
const scanEnabledTriggersSchema = z.object({
|
|
2084
|
-
traceId: traceIdSchema,
|
|
2085
|
-
});
|
|
2086
|
-
const patchTriggerFiredSchema = z.object({
|
|
2087
|
-
triggerId: z.string().trim().min(1),
|
|
2088
|
-
fireKey: z.string().trim().min(1),
|
|
2089
|
-
expectedLastFireKey: z.string().nullable().optional(),
|
|
2090
|
-
traceId: traceIdSchema,
|
|
2091
|
-
});
|
|
2092
|
-
function enabledTriggerFilter() {
|
|
2093
|
-
return [{ field: TRIGGER_STATUS_FIELD, operator: "eq", value: TriggerStatus.ENABLED }];
|
|
2094
|
-
}
|
|
2095
|
-
/** List all ENABLED triggers (Phase 1: in-memory due check). */
|
|
2096
|
-
const scanEnabledTriggers = validateWithSchema(scanEnabledTriggersSchema, "scanEnabledTriggersSchema")(async ({ traceId }) => {
|
|
2097
|
-
const logger = createLogger({ module: "scanEnabledTriggers", traceId });
|
|
2098
|
-
try {
|
|
2099
|
-
const { data: triggerList } = await scanTargetList({
|
|
2100
|
-
category: CategoryTrigger.TRIGGER,
|
|
2101
|
-
filterList: enabledTriggerFilter(),
|
|
2102
|
-
orderBy: { field: "created_at", ascending: true },
|
|
2103
|
-
});
|
|
2104
|
-
const rows = triggerList ?? [];
|
|
2105
|
-
logger.debug("已掃描 ENABLED Trigger", {
|
|
2106
|
-
topic: "trigger",
|
|
2107
|
-
data: { count: rows.length },
|
|
2108
|
-
});
|
|
2109
|
-
return generateResponse.success(rows);
|
|
2110
|
-
}
|
|
2111
|
-
catch (error) {
|
|
2112
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2113
|
-
return generateResponse.error(message);
|
|
2114
|
-
}
|
|
2115
|
-
});
|
|
2116
|
-
/** Create a trigger row (scheduler / admin). */
|
|
2117
|
-
const postTrigger = validateWithSchema(postTriggerSchema, "postTriggerSchema")(async ({ name, value, status, schedule, action, tagList, traceId }) => {
|
|
2118
|
-
const logger = createLogger({ module: "postTrigger", traceId });
|
|
2119
|
-
const details = {
|
|
2120
|
-
manifestVersion: 0,
|
|
2121
|
-
status,
|
|
2122
|
-
schedule: schedule,
|
|
2123
|
-
action: action,
|
|
2124
|
-
lastFiredAt: null,
|
|
2125
|
-
lastFireKey: null,
|
|
2712
|
+
return {
|
|
2713
|
+
isSuccess: false,
|
|
2714
|
+
taskFn: null,
|
|
2715
|
+
code: params.code,
|
|
2716
|
+
message: params.message,
|
|
2717
|
+
reason: params.reason,
|
|
2718
|
+
step: params.step,
|
|
2719
|
+
};
|
|
2126
2720
|
};
|
|
2127
|
-
const
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
value,
|
|
2132
|
-
category: CategoryTrigger.TRIGGER,
|
|
2133
|
-
tagList,
|
|
2134
|
-
details,
|
|
2135
|
-
}),
|
|
2136
|
-
});
|
|
2137
|
-
logger.info("Trigger 已創建", {
|
|
2138
|
-
topic: "trigger",
|
|
2139
|
-
data: { triggerId: result.data?.id, triggerKey: value, status },
|
|
2721
|
+
const { id: taskId, name: taskName, value: taskTypeKey, details } = task;
|
|
2722
|
+
logger.debug(`開始準備任務 ${taskName}-${taskTypeKey}`, {
|
|
2723
|
+
topic: LOG_TOPIC_TASK,
|
|
2724
|
+
data: { taskId, taskName, taskTypeKey },
|
|
2140
2725
|
});
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
*/
|
|
2147
|
-
const patchTriggerFired = validateWithSchema(patchTriggerFiredSchema, "patchTriggerFiredSchema")(async ({ triggerId, fireKey, expectedLastFireKey, traceId }) => {
|
|
2148
|
-
const logger = createLogger({ module: "patchTriggerFired", traceId });
|
|
2149
|
-
const now = Date.now();
|
|
2150
|
-
try {
|
|
2151
|
-
const optimisticLockFilterList = expectedLastFireKey != null && expectedLastFireKey !== ""
|
|
2152
|
-
? [{ field: "details->>lastFireKey", operator: "eq", value: expectedLastFireKey }]
|
|
2153
|
-
: [];
|
|
2154
|
-
const data = await updateTargetDetails({
|
|
2155
|
-
id: triggerId,
|
|
2156
|
-
optimisticLockFilterList,
|
|
2157
|
-
updateFn: (current) => ({
|
|
2158
|
-
...current,
|
|
2159
|
-
lastFireKey: fireKey,
|
|
2160
|
-
lastFiredAt: now,
|
|
2161
|
-
}),
|
|
2726
|
+
const { params: taskParams } = details;
|
|
2727
|
+
if (!getRegisteredLocalTaskTypeKeys().includes(taskTypeKey)) {
|
|
2728
|
+
const bootstrap = await bootstrapLocalTasks({
|
|
2729
|
+
...bootstrapLocalOptions,
|
|
2730
|
+
forTaskTypeKey: taskTypeKey,
|
|
2162
2731
|
});
|
|
2163
|
-
logger.
|
|
2164
|
-
topic:
|
|
2165
|
-
data: {
|
|
2732
|
+
logger.debug("本地 registry 未命中,補掃本地任務", {
|
|
2733
|
+
topic: LOG_TOPIC_TASK,
|
|
2734
|
+
data: {
|
|
2735
|
+
taskId,
|
|
2736
|
+
taskTypeKey,
|
|
2737
|
+
step: "local_bootstrap_on_miss",
|
|
2738
|
+
status: bootstrap.status,
|
|
2739
|
+
registered: bootstrap.registered,
|
|
2740
|
+
cached: bootstrap.cached ?? false,
|
|
2741
|
+
message: bootstrap.message,
|
|
2742
|
+
},
|
|
2166
2743
|
});
|
|
2167
|
-
return generateResponse.success(data);
|
|
2168
2744
|
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2745
|
+
const validation = await TaskRepoValidation.validate({
|
|
2746
|
+
logger,
|
|
2747
|
+
taskTypeKey,
|
|
2748
|
+
params: taskParams,
|
|
2749
|
+
includeRemote,
|
|
2750
|
+
});
|
|
2751
|
+
if (!validation.isValid) {
|
|
2752
|
+
return fail({
|
|
2753
|
+
reason: validation.reason,
|
|
2754
|
+
code: validation.code,
|
|
2755
|
+
message: validation.message,
|
|
2756
|
+
step: validation.step,
|
|
2174
2757
|
});
|
|
2175
|
-
return generateResponse.error(message);
|
|
2176
|
-
}
|
|
2177
|
-
});
|
|
2178
|
-
|
|
2179
|
-
const LOG_TOPIC_TRIGGER = "trigger";
|
|
2180
|
-
/** Fixed main-loop interval for {@link TriggerNode} (ms). */
|
|
2181
|
-
const TRIGGER_LOOP_INTERVAL_MS = 60_000;
|
|
2182
|
-
/** Default extra retries after first failure within one runner tick. */
|
|
2183
|
-
const TRIGGER_RUNNER_DEFAULT_RETRY_COUNT = 3;
|
|
2184
|
-
/** Default delay between retry attempts within the same tick (ms). */
|
|
2185
|
-
const TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS = 0;
|
|
2186
|
-
|
|
2187
|
-
const runnerRegistry = new Map();
|
|
2188
|
-
let registrationClosed = false;
|
|
2189
|
-
function normalizeRunnerKey(key) {
|
|
2190
|
-
const normalized = key.trim();
|
|
2191
|
-
if (normalized.length === 0) {
|
|
2192
|
-
throw new Error("[TriggerManager] key must be a non-empty string");
|
|
2193
|
-
}
|
|
2194
|
-
return normalized;
|
|
2195
|
-
}
|
|
2196
|
-
function tryNormalizeRunnerKey(key) {
|
|
2197
|
-
const normalized = key.trim();
|
|
2198
|
-
return normalized.length > 0 ? normalized : null;
|
|
2199
|
-
}
|
|
2200
|
-
function validateRunnerOptions(options) {
|
|
2201
|
-
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 1) {
|
|
2202
|
-
throw new Error(`[TriggerManager] intervalMs must be a positive number, got ${options.intervalMs}`);
|
|
2203
|
-
}
|
|
2204
|
-
const retryCount = options.retryCount ?? TRIGGER_RUNNER_DEFAULT_RETRY_COUNT;
|
|
2205
|
-
if (!Number.isInteger(retryCount) || retryCount < 0) {
|
|
2206
|
-
throw new Error(`[TriggerManager] retryCount must be a non-negative integer, got ${retryCount}`);
|
|
2207
|
-
}
|
|
2208
|
-
const initialDelayMs = options.initialDelayMs ?? 0;
|
|
2209
|
-
if (!Number.isFinite(initialDelayMs) || initialDelayMs < 0) {
|
|
2210
|
-
throw new Error(`[TriggerManager] initialDelayMs must be a non-negative number, got ${initialDelayMs}`);
|
|
2211
|
-
}
|
|
2212
|
-
const retryDelayMs = options.retryDelayMs ?? TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS;
|
|
2213
|
-
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
2214
|
-
throw new Error(`[TriggerManager] retryDelayMs must be a non-negative number, got ${retryDelayMs}`);
|
|
2215
|
-
}
|
|
2216
|
-
if (options.timeoutMs != null) {
|
|
2217
|
-
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1) {
|
|
2218
|
-
throw new Error(`[TriggerManager] timeoutMs must be a positive number when set, got ${options.timeoutMs}`);
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
function delay(ms) {
|
|
2223
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2224
|
-
}
|
|
2225
|
-
async function invokeRunnerFn(fn, ctx, timeoutMs) {
|
|
2226
|
-
if (timeoutMs == null) {
|
|
2227
|
-
await fn(ctx);
|
|
2228
|
-
return;
|
|
2229
2758
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2759
|
+
const { repoContext } = validation;
|
|
2760
|
+
logger.debug("Repo 上下文加載成功", {
|
|
2761
|
+
topic: LOG_TOPIC_TASK,
|
|
2762
|
+
data: {
|
|
2763
|
+
taskTypeKey,
|
|
2764
|
+
taskFnDisplayName: repoContext.taskFn.displayName,
|
|
2765
|
+
step: "repo_load",
|
|
2766
|
+
},
|
|
2235
2767
|
});
|
|
2236
|
-
|
|
2237
|
-
|
|
2768
|
+
const boundTaskFn = bindTaskFn(repoContext.taskFn, taskParams, {
|
|
2769
|
+
taskId,
|
|
2770
|
+
taskName,
|
|
2771
|
+
taskTypeKey,
|
|
2772
|
+
traceId: details.traceId ?? taskId,
|
|
2773
|
+
});
|
|
2774
|
+
logger.debug("任務準備完成", {
|
|
2775
|
+
topic: LOG_TOPIC_TASK,
|
|
2776
|
+
data: {
|
|
2777
|
+
taskId,
|
|
2778
|
+
taskTypeKey,
|
|
2779
|
+
displayName: boundTaskFn.displayName,
|
|
2780
|
+
step: "bind",
|
|
2781
|
+
},
|
|
2782
|
+
});
|
|
2783
|
+
return {
|
|
2784
|
+
isSuccess: true,
|
|
2785
|
+
taskFn: boundTaskFn,
|
|
2786
|
+
};
|
|
2787
|
+
};
|
|
2788
|
+
const TaskManager = {
|
|
2789
|
+
registerTasks,
|
|
2790
|
+
prepareTask,
|
|
2791
|
+
bootstrapLocalTasks,
|
|
2792
|
+
getRegisteredLocalTaskTypeKeys,
|
|
2793
|
+
clearBootstrapLocalTasksCache,
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
/**
|
|
2797
|
+
* Task worker node: commands → heartbeat → claim & execute tasks.
|
|
2798
|
+
* Built on {@link BaseNodeRuntime}.
|
|
2799
|
+
*/
|
|
2800
|
+
class TaskNode extends BaseNodeRuntime {
|
|
2801
|
+
_availableTaskList = [];
|
|
2802
|
+
_includeRemote = true;
|
|
2803
|
+
get availableTaskList() {
|
|
2804
|
+
return [...this._availableTaskList];
|
|
2238
2805
|
}
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
clearTimeout(timeoutHandle);
|
|
2242
|
-
}
|
|
2806
|
+
constructor() {
|
|
2807
|
+
super("taskNode");
|
|
2243
2808
|
}
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
runnerRegistry.set(key, {
|
|
2261
|
-
key,
|
|
2262
|
-
intervalMs: options.intervalMs,
|
|
2263
|
-
fn: options.fn,
|
|
2264
|
-
retryCount: options.retryCount ?? TRIGGER_RUNNER_DEFAULT_RETRY_COUNT,
|
|
2265
|
-
retryDelayMs: options.retryDelayMs ?? TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS,
|
|
2266
|
-
timeoutMs: options.timeoutMs,
|
|
2267
|
-
nextRunAt: now + (options.initialDelayMs ?? 0),
|
|
2268
|
-
running: false,
|
|
2269
|
-
});
|
|
2270
|
-
},
|
|
2271
|
-
hasRunner(key) {
|
|
2272
|
-
const normalized = tryNormalizeRunnerKey(key);
|
|
2273
|
-
return normalized != null && runnerRegistry.has(normalized);
|
|
2274
|
-
},
|
|
2275
|
-
unregisterRunner(key) {
|
|
2276
|
-
const normalized = tryNormalizeRunnerKey(key);
|
|
2277
|
-
if (normalized == null) {
|
|
2278
|
-
return false;
|
|
2279
|
-
}
|
|
2280
|
-
const runner = runnerRegistry.get(normalized);
|
|
2281
|
-
if (runner == null) {
|
|
2282
|
-
return false;
|
|
2283
|
-
}
|
|
2284
|
-
if (runner.running) {
|
|
2285
|
-
throw new Error(`[TriggerManager] Cannot unregister running runner: ${normalized}`);
|
|
2286
|
-
}
|
|
2287
|
-
return runnerRegistry.delete(normalized);
|
|
2288
|
-
},
|
|
2289
|
-
getRunnerKeys() {
|
|
2290
|
-
return [...runnerRegistry.keys()];
|
|
2291
|
-
},
|
|
2292
|
-
getRunnersBelowLoopInterval() {
|
|
2293
|
-
return [...runnerRegistry.values()]
|
|
2294
|
-
.filter((runner) => runner.intervalMs < TRIGGER_LOOP_INTERVAL_MS)
|
|
2295
|
-
.map((runner) => ({ key: runner.key, intervalMs: runner.intervalMs }));
|
|
2296
|
-
},
|
|
2297
|
-
/** Called by TriggerNode during bootstrap — blocks further registerRunner calls. */
|
|
2298
|
-
closeRegistration() {
|
|
2299
|
-
registrationClosed = true;
|
|
2300
|
-
},
|
|
2301
|
-
/** Re-open registration (tests only). */
|
|
2302
|
-
_resetRegistrationForTests() {
|
|
2303
|
-
registrationClosed = false;
|
|
2304
|
-
},
|
|
2305
|
-
/**
|
|
2306
|
-
* Remove all runners. Throws if any runner is still executing.
|
|
2307
|
-
* Wait for in-flight ticks to finish before calling in tests.
|
|
2308
|
-
*/
|
|
2309
|
-
clearRunners() {
|
|
2310
|
-
for (const runner of runnerRegistry.values()) {
|
|
2311
|
-
if (runner.running) {
|
|
2312
|
-
throw new Error(`[TriggerManager] Cannot clearRunners while runner is running: ${runner.key}`);
|
|
2313
|
-
}
|
|
2809
|
+
async onBeforeRegisterNode(logger) {
|
|
2810
|
+
const { availableTaskList, includeRemote } = await TaskManager.registerTasks({ logger });
|
|
2811
|
+
this._availableTaskList = availableTaskList;
|
|
2812
|
+
this._includeRemote = includeRemote;
|
|
2813
|
+
}
|
|
2814
|
+
async runLoopSteps(ctx, heartbeatOk) {
|
|
2815
|
+
if (!heartbeatOk) {
|
|
2816
|
+
const logger = createLogger({
|
|
2817
|
+
scope: createScope({
|
|
2818
|
+
module: `${this.runtimeModule}-runLoopSteps`,
|
|
2819
|
+
traceId: ctx.loopTraceId,
|
|
2820
|
+
labels: { nodeId: ctx.nodeId },
|
|
2821
|
+
}),
|
|
2822
|
+
});
|
|
2823
|
+
logger.warn("心跳失敗,本輪跳過任務認領", { topic: LOG_TOPIC_NODE });
|
|
2824
|
+
return;
|
|
2314
2825
|
}
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
async tick(ctx) {
|
|
2826
|
+
await this.runAsWorker(ctx);
|
|
2827
|
+
}
|
|
2828
|
+
async runAsWorker(ctx) {
|
|
2319
2829
|
const { loopTraceId, nodeId } = ctx;
|
|
2320
|
-
const loopScope = createScope({ module: "
|
|
2830
|
+
const loopScope = createScope({ module: "runAsWorker", traceId: loopTraceId, labels: { nodeId } });
|
|
2321
2831
|
const logger = createLogger({ scope: loopScope });
|
|
2322
|
-
const
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
logger.debug("無 runner 可調度", { topic: LOG_TOPIC_TRIGGER });
|
|
2832
|
+
const availableTaskList = this.availableTaskList;
|
|
2833
|
+
if (availableTaskList.length === 0) {
|
|
2834
|
+
logger.warn("未配置可認領任務類型,跳過本輪", { topic: LOG_TOPIC_TASK });
|
|
2326
2835
|
return;
|
|
2327
2836
|
}
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2837
|
+
logger.debug("向 Supabase 認領任務", {
|
|
2838
|
+
topic: LOG_TOPIC_TASK,
|
|
2839
|
+
data: { availableTaskList },
|
|
2840
|
+
});
|
|
2841
|
+
const { data: task, error: claimError } = await patchClaimTask({
|
|
2842
|
+
nodeId,
|
|
2843
|
+
availableTaskList: [...availableTaskList],
|
|
2844
|
+
traceId: loopTraceId,
|
|
2845
|
+
});
|
|
2846
|
+
if (claimError) {
|
|
2847
|
+
if (isOptimisticLockResponse(claimError)) {
|
|
2848
|
+
logger.debug("認領競爭失敗,本輪無可認領任務", {
|
|
2849
|
+
topic: LOG_TOPIC_TASK,
|
|
2850
|
+
data: { availableTaskList },
|
|
2851
|
+
});
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
logger.error("認領任務失敗", {
|
|
2855
|
+
topic: LOG_TOPIC_TASK,
|
|
2856
|
+
data: {
|
|
2857
|
+
availableTaskList,
|
|
2858
|
+
error: claimError.message,
|
|
2859
|
+
code: claimError.code,
|
|
2860
|
+
},
|
|
2861
|
+
});
|
|
2333
2862
|
return;
|
|
2334
2863
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2864
|
+
if (task == null) {
|
|
2865
|
+
logger.debug("本輪無匹配的 TODO 任務", {
|
|
2866
|
+
topic: LOG_TOPIC_TASK,
|
|
2867
|
+
data: { availableTaskList },
|
|
2868
|
+
});
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
const taskTraceId = task.details.traceId ?? CRITICAL_UNKNOWN_TASK_TRACE_ID;
|
|
2872
|
+
const taskScope = createScope({
|
|
2873
|
+
module: "runAsWorker",
|
|
2874
|
+
traceId: loopTraceId,
|
|
2875
|
+
labels: { nodeId },
|
|
2876
|
+
traceParentId: taskTraceId,
|
|
2338
2877
|
});
|
|
2339
|
-
const
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2878
|
+
const taskLogger = createLogger({ scope: taskScope });
|
|
2879
|
+
taskLogger.success("認領任務成功,準備執行", {
|
|
2880
|
+
topic: LOG_TOPIC_TASK,
|
|
2881
|
+
data: {
|
|
2882
|
+
taskId: task.id,
|
|
2883
|
+
taskName: task.name,
|
|
2884
|
+
taskTypeKey: task.value,
|
|
2885
|
+
taskStatus: task.details.status,
|
|
2886
|
+
},
|
|
2887
|
+
});
|
|
2888
|
+
const prepareLogger = createLogger({ scope: withModule(taskScope, "prepareTask") });
|
|
2889
|
+
const prepareResult = await TaskManager.prepareTask({
|
|
2890
|
+
logger: prepareLogger,
|
|
2891
|
+
task,
|
|
2892
|
+
includeRemote: this._includeRemote,
|
|
2893
|
+
});
|
|
2894
|
+
const { isSuccess: isPrepareSuccess, taskFn, code, message, reason, step } = prepareResult;
|
|
2895
|
+
if (!isPrepareSuccess || taskFn == null) {
|
|
2896
|
+
prepareLogger.critical("任務準備失敗,無法繼續執行,中止任務並 RESET 為 OPEN", {
|
|
2897
|
+
topic: LOG_TOPIC_TASK,
|
|
2898
|
+
data: {
|
|
2899
|
+
taskId: task.id,
|
|
2900
|
+
taskTypeKey: task.value,
|
|
2901
|
+
code,
|
|
2902
|
+
message,
|
|
2903
|
+
reason,
|
|
2904
|
+
step,
|
|
2905
|
+
},
|
|
2906
|
+
});
|
|
2907
|
+
await this.abortTaskRun({
|
|
2908
|
+
logger: prepareLogger,
|
|
2909
|
+
taskId: task.id,
|
|
2910
|
+
nodeId,
|
|
2911
|
+
traceId: taskTraceId,
|
|
2345
2912
|
});
|
|
2913
|
+
return;
|
|
2346
2914
|
}
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2915
|
+
logger.success("任務準備完成", {
|
|
2916
|
+
topic: LOG_TOPIC_TASK,
|
|
2917
|
+
data: {
|
|
2918
|
+
taskId: task.id,
|
|
2919
|
+
taskName: task.name,
|
|
2920
|
+
taskTypeKey: task.value,
|
|
2921
|
+
displayName: taskFn.displayName,
|
|
2922
|
+
},
|
|
2923
|
+
});
|
|
2924
|
+
const executeLogger = createLogger({ scope: withModule(taskScope, "executeTask") });
|
|
2925
|
+
const onExecuteAbort = async (error) => {
|
|
2926
|
+
executeLogger.critical("任務執行遇到未知錯誤,中止任務並 RESET 為 OPEN", {
|
|
2927
|
+
topic: LOG_TOPIC_TASK,
|
|
2350
2928
|
data: {
|
|
2351
|
-
|
|
2352
|
-
|
|
2929
|
+
taskId: task.id,
|
|
2930
|
+
taskTypeKey: task.value,
|
|
2931
|
+
taskName: task.name,
|
|
2932
|
+
displayName: taskFn.displayName,
|
|
2933
|
+
error: getErrorMessage(error),
|
|
2353
2934
|
},
|
|
2354
2935
|
});
|
|
2936
|
+
await this.abortTaskRun({
|
|
2937
|
+
logger: executeLogger,
|
|
2938
|
+
taskId: task.id,
|
|
2939
|
+
nodeId,
|
|
2940
|
+
traceId: taskTraceId,
|
|
2941
|
+
});
|
|
2942
|
+
};
|
|
2943
|
+
executeLogger.info("開始執行業務邏輯", {
|
|
2944
|
+
topic: LOG_TOPIC_TASK,
|
|
2945
|
+
data: { taskId: task.id, displayName: taskFn.displayName },
|
|
2946
|
+
});
|
|
2947
|
+
const startTime = Date.now();
|
|
2948
|
+
let result = null;
|
|
2949
|
+
try {
|
|
2950
|
+
result = await taskFn();
|
|
2355
2951
|
}
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2952
|
+
catch (error) {
|
|
2953
|
+
await onExecuteAbort(toError(error));
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
if (result == null) {
|
|
2957
|
+
await onExecuteAbort(new Error("任務執行結果為空"));
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
const { isSuccess: isTaskSuccess, cost, extra } = result ?? { isSuccess: false, cost: 0, extra: null };
|
|
2961
|
+
if (isTaskSuccess) {
|
|
2962
|
+
await this.finalizeTaskRun("success", {
|
|
2963
|
+
logger: executeLogger,
|
|
2964
|
+
taskId: task.id,
|
|
2965
|
+
taskTypeKey: task.value,
|
|
2966
|
+
nodeId,
|
|
2967
|
+
cost,
|
|
2968
|
+
extra,
|
|
2969
|
+
traceId: taskTraceId,
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
else {
|
|
2973
|
+
await this.finalizeTaskRun("failure", {
|
|
2974
|
+
logger: executeLogger,
|
|
2975
|
+
taskId: task.id,
|
|
2976
|
+
taskTypeKey: task.value,
|
|
2977
|
+
nodeId,
|
|
2978
|
+
cost,
|
|
2979
|
+
extra,
|
|
2980
|
+
traceId: taskTraceId,
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
const duration = Date.now() - startTime;
|
|
2984
|
+
executeLogger.info("業務邏輯執行結束", {
|
|
2985
|
+
topic: LOG_TOPIC_TASK,
|
|
2986
|
+
data: {
|
|
2987
|
+
taskId: task.id,
|
|
2988
|
+
taskName: task.name,
|
|
2989
|
+
taskTypeKey: task.value,
|
|
2990
|
+
displayName: taskFn.displayName,
|
|
2991
|
+
durationMs: duration,
|
|
2992
|
+
},
|
|
2993
|
+
});
|
|
2362
2994
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
loopTraceId,
|
|
2378
|
-
nodeId,
|
|
2379
|
-
runnerKey: runner.key,
|
|
2380
|
-
logger: runnerLogger,
|
|
2381
|
-
attempt,
|
|
2382
|
-
maxAttempts,
|
|
2383
|
-
}, runner.timeoutMs);
|
|
2384
|
-
runnerLogger.success("Runner 完成", {
|
|
2385
|
-
topic: LOG_TOPIC_TRIGGER,
|
|
2386
|
-
data: { attempt, maxAttempts },
|
|
2387
|
-
});
|
|
2388
|
-
succeeded = true;
|
|
2389
|
-
break;
|
|
2390
|
-
}
|
|
2391
|
-
catch (error) {
|
|
2392
|
-
const message = getErrorMessage(error);
|
|
2393
|
-
if (attempt < maxAttempts) {
|
|
2394
|
-
runnerLogger.warn("Runner 失敗,準備重試", {
|
|
2395
|
-
topic: LOG_TOPIC_TRIGGER,
|
|
2396
|
-
data: { attempt, maxAttempts, error: message },
|
|
2397
|
-
});
|
|
2398
|
-
if (runner.retryDelayMs > 0) {
|
|
2399
|
-
await delay(runner.retryDelayMs);
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
else {
|
|
2403
|
-
runnerLogger.error("Runner 重試用盡仍失敗", {
|
|
2404
|
-
topic: LOG_TOPIC_TRIGGER,
|
|
2405
|
-
data: { attempt, maxAttempts, error: message },
|
|
2406
|
-
});
|
|
2407
|
-
}
|
|
2408
|
-
}
|
|
2995
|
+
async abortTaskRun(params) {
|
|
2996
|
+
const { logger, taskId, nodeId, traceId, extra } = params;
|
|
2997
|
+
const logData = { taskId, nodeId };
|
|
2998
|
+
const extraValue = typeof extra === "string" ? extra : undefined;
|
|
2999
|
+
const { error } = await patchChangeTaskStatus({
|
|
3000
|
+
id: taskId,
|
|
3001
|
+
action: TaskStatusAction.RESET,
|
|
3002
|
+
nodeId,
|
|
3003
|
+
extra: extraValue,
|
|
3004
|
+
traceId,
|
|
3005
|
+
});
|
|
3006
|
+
if (error) {
|
|
3007
|
+
logger.error(error.message, { topic: LOG_TOPIC_TASK, data: logData });
|
|
3008
|
+
return false;
|
|
2409
3009
|
}
|
|
3010
|
+
logger.info("任務已中止並 RESET 為 OPEN", { topic: LOG_TOPIC_TASK, data: logData });
|
|
3011
|
+
return true;
|
|
2410
3012
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
3013
|
+
async finalizeTaskRun(outcome, params) {
|
|
3014
|
+
const { logger, taskId, nodeId, taskTypeKey, cost, extra, traceId } = params;
|
|
3015
|
+
const outcomePrefix = outcome === "success" ? "任務成功" : "任務失敗";
|
|
3016
|
+
const logData = {
|
|
3017
|
+
taskTypeKey,
|
|
3018
|
+
taskId,
|
|
3019
|
+
nodeId,
|
|
3020
|
+
...(extra != null && extra !== "" ? { extra } : {}),
|
|
3021
|
+
};
|
|
3022
|
+
const extraValue = typeof extra === "string" ? extra : undefined;
|
|
3023
|
+
const { error: changeTaskStatusError } = outcome === "success"
|
|
3024
|
+
? await patchChangeTaskStatus({
|
|
3025
|
+
id: taskId,
|
|
3026
|
+
action: TaskStatusAction.FINISH,
|
|
3027
|
+
nodeId,
|
|
3028
|
+
cost,
|
|
3029
|
+
extra: extraValue,
|
|
3030
|
+
traceId,
|
|
3031
|
+
})
|
|
3032
|
+
: await patchChangeTaskStatus({
|
|
3033
|
+
id: taskId,
|
|
3034
|
+
action: TaskStatusAction.RESET,
|
|
3035
|
+
nodeId,
|
|
3036
|
+
extra: extraValue,
|
|
3037
|
+
traceId,
|
|
3038
|
+
});
|
|
3039
|
+
if (changeTaskStatusError) {
|
|
3040
|
+
const upperMessage = `${outcomePrefix}但是更新 TaskStatus 失敗,產生孤兒 Task,請儘快排查問題`;
|
|
3041
|
+
logger.critical(upperMessage, { topic: LOG_TOPIC_TASK, data: logData });
|
|
3042
|
+
// TODO: 需要處理孤兒 Task
|
|
3043
|
+
throw new Error(upperMessage);
|
|
3044
|
+
}
|
|
3045
|
+
logger.info("更新 TaskStatus 成功", { topic: LOG_TOPIC_TASK, data: logData });
|
|
3046
|
+
if (outcome === "success") {
|
|
3047
|
+
logger.success("任務執行結束,業務邏輯成功", { topic: LOG_TOPIC_TASK, data: logData });
|
|
3048
|
+
}
|
|
3049
|
+
else {
|
|
3050
|
+
logger.warn("任務執行結束,業務邏輯失敗", { topic: LOG_TOPIC_TASK, data: logData });
|
|
3051
|
+
}
|
|
2414
3052
|
}
|
|
2415
|
-
return succeeded;
|
|
2416
3053
|
}
|
|
2417
3054
|
|
|
2418
3055
|
/**
|
|
2419
|
-
*
|
|
2420
|
-
*
|
|
2421
|
-
*
|
|
2422
|
-
* Register runners via {@link TriggerManager.registerRunner} before {@link start}.
|
|
2423
|
-
* Does not scan Supabase trigger rows — see `trigger.api.ts` for remote admin APIs.
|
|
3056
|
+
* Create a task row with publish-time Repo + params validation.
|
|
3057
|
+
* **Node entry only** — requires local Repo bootstrap and {@link TaskRepoValidation}.
|
|
2424
3058
|
*
|
|
2425
|
-
*
|
|
3059
|
+
* @see target-supabase-sdk/node
|
|
2426
3060
|
*/
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
3061
|
+
const postTaskWithValidation = validateWithSchema(postTaskSchema, "postTaskSchema")(async ({ name, value, params, taskStatus, tagList, extra, traceId, traceParentId }) => {
|
|
3062
|
+
const taskTraceId = traceId?.trim() || logManager.generateTraceId();
|
|
3063
|
+
const logger = createLogger({
|
|
3064
|
+
module: "postTaskWithValidation",
|
|
3065
|
+
traceId: taskTraceId,
|
|
3066
|
+
traceParentId: traceParentId ?? null,
|
|
3067
|
+
});
|
|
3068
|
+
const validation = await TaskRepoValidation.validate({
|
|
3069
|
+
logger,
|
|
3070
|
+
taskTypeKey: value,
|
|
3071
|
+
params,
|
|
3072
|
+
bootstrapLocal: true,
|
|
3073
|
+
});
|
|
3074
|
+
if (!validation.isValid) {
|
|
3075
|
+
logger.warn(validation.message, {
|
|
3076
|
+
topic: "task",
|
|
3077
|
+
data: {
|
|
3078
|
+
taskTypeKey: value,
|
|
3079
|
+
reason: validation.reason,
|
|
3080
|
+
step: validation.step,
|
|
3081
|
+
},
|
|
3082
|
+
});
|
|
3083
|
+
return generateResponse.error(validation.message, undefined, String(validation.code));
|
|
2432
3084
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
3085
|
+
return createTaskRow({ name, value, params, taskStatus, tagList, extra, traceParentId, taskTraceId }, logger);
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
var CategoryTrigger;
|
|
3089
|
+
(function (CategoryTrigger) {
|
|
3090
|
+
CategoryTrigger["TRIGGER"] = "trigger";
|
|
3091
|
+
})(CategoryTrigger || (CategoryTrigger = {}));
|
|
3092
|
+
var TriggerStatus;
|
|
3093
|
+
(function (TriggerStatus) {
|
|
3094
|
+
TriggerStatus["ENABLED"] = "ENABLED";
|
|
3095
|
+
TriggerStatus["DISABLED"] = "DISABLED";
|
|
3096
|
+
TriggerStatus["PAUSED"] = "PAUSED";
|
|
3097
|
+
})(TriggerStatus || (TriggerStatus = {}));
|
|
3098
|
+
|
|
3099
|
+
const TRIGGER_STATUS_FIELD = "details->>status";
|
|
3100
|
+
const traceIdSchema = z.string().trim().min(1).optional();
|
|
3101
|
+
const dailyScheduleSchema = z.object({
|
|
3102
|
+
kind: z.literal("daily"),
|
|
3103
|
+
hour: z.number().int().min(0).max(23),
|
|
3104
|
+
minute: z.number().int().min(0).max(59),
|
|
3105
|
+
});
|
|
3106
|
+
const postTaskActionSchema = z.object({
|
|
3107
|
+
kind: z.literal("post_task"),
|
|
3108
|
+
taskTypeKey: z.string().trim().min(1),
|
|
3109
|
+
taskParams: z.unknown(),
|
|
3110
|
+
taskName: z.string().trim().min(1).optional(),
|
|
3111
|
+
taskStatus: z.enum([TaskStatus.OPEN, TaskStatus.TODO]).optional(),
|
|
3112
|
+
});
|
|
3113
|
+
const postTriggerSchema = z.object({
|
|
3114
|
+
name: z.string().trim().min(1),
|
|
3115
|
+
value: z.string().trim().min(1),
|
|
3116
|
+
status: z.nativeEnum(TriggerStatus).optional().default(TriggerStatus.ENABLED),
|
|
3117
|
+
schedule: dailyScheduleSchema,
|
|
3118
|
+
action: postTaskActionSchema,
|
|
3119
|
+
tagList: z.array(z.string()).optional().default([]),
|
|
3120
|
+
traceId: traceIdSchema,
|
|
3121
|
+
});
|
|
3122
|
+
const scanEnabledTriggersSchema = z.object({
|
|
3123
|
+
traceId: traceIdSchema,
|
|
3124
|
+
});
|
|
3125
|
+
const patchTriggerFiredSchema = z.object({
|
|
3126
|
+
triggerId: z.string().trim().min(1),
|
|
3127
|
+
fireKey: z.string().trim().min(1),
|
|
3128
|
+
expectedLastFireKey: z.string().nullable().optional(),
|
|
3129
|
+
traceId: traceIdSchema,
|
|
3130
|
+
});
|
|
3131
|
+
function enabledTriggerFilter() {
|
|
3132
|
+
return [{ field: TRIGGER_STATUS_FIELD, operator: "eq", value: TriggerStatus.ENABLED }];
|
|
3133
|
+
}
|
|
3134
|
+
/** List all ENABLED triggers (Phase 1: in-memory due check). */
|
|
3135
|
+
const scanEnabledTriggers = validateWithSchema(scanEnabledTriggersSchema, "scanEnabledTriggersSchema")(async ({ traceId }) => {
|
|
3136
|
+
const logger = createLogger({ module: "scanEnabledTriggers", traceId });
|
|
3137
|
+
try {
|
|
3138
|
+
const { data: triggerList } = await scanTargetList({
|
|
3139
|
+
category: CategoryTrigger.TRIGGER,
|
|
3140
|
+
filterList: enabledTriggerFilter(),
|
|
3141
|
+
orderBy: { field: "created_at", ascending: true },
|
|
3142
|
+
});
|
|
3143
|
+
const rows = triggerList ?? [];
|
|
3144
|
+
logger.debug("已掃描 ENABLED Trigger", {
|
|
3145
|
+
topic: "trigger",
|
|
3146
|
+
data: { count: rows.length },
|
|
3147
|
+
});
|
|
3148
|
+
return generateResponse.success(rows);
|
|
2435
3149
|
}
|
|
2436
|
-
|
|
2437
|
-
|
|
3150
|
+
catch (error) {
|
|
3151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3152
|
+
return generateResponse.error(message);
|
|
2438
3153
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
3154
|
+
});
|
|
3155
|
+
/** Create a trigger row (scheduler / admin). */
|
|
3156
|
+
const postTrigger = validateWithSchema(postTriggerSchema, "postTriggerSchema")(async ({ name, value, status, schedule, action, tagList, traceId }) => {
|
|
3157
|
+
const logger = createLogger({ module: "postTrigger", traceId });
|
|
3158
|
+
const details = {
|
|
3159
|
+
manifestVersion: 0,
|
|
3160
|
+
status,
|
|
3161
|
+
schedule: schedule,
|
|
3162
|
+
action: action,
|
|
3163
|
+
lastFiredAt: null,
|
|
3164
|
+
lastFireKey: null,
|
|
3165
|
+
};
|
|
3166
|
+
const result = await createTarget({
|
|
3167
|
+
payload: { name, value, status, schedule, action, tagList, traceId },
|
|
3168
|
+
createFn: () => ({
|
|
3169
|
+
name,
|
|
3170
|
+
value,
|
|
3171
|
+
category: CategoryTrigger.TRIGGER,
|
|
3172
|
+
tagList,
|
|
3173
|
+
details,
|
|
3174
|
+
}),
|
|
3175
|
+
});
|
|
3176
|
+
logger.info("Trigger 已創建", {
|
|
3177
|
+
topic: "trigger",
|
|
3178
|
+
data: { triggerId: result.data?.id, triggerKey: value, status },
|
|
3179
|
+
});
|
|
3180
|
+
return result;
|
|
3181
|
+
});
|
|
3182
|
+
/**
|
|
3183
|
+
* Mark trigger as fired for `fireKey`.
|
|
3184
|
+
* When `expectedLastFireKey` is set, uses optimistic lock on that field.
|
|
3185
|
+
*/
|
|
3186
|
+
const patchTriggerFired = validateWithSchema(patchTriggerFiredSchema, "patchTriggerFiredSchema")(async ({ triggerId, fireKey, expectedLastFireKey, traceId }) => {
|
|
3187
|
+
const logger = createLogger({ module: "patchTriggerFired", traceId });
|
|
3188
|
+
const now = Date.now();
|
|
3189
|
+
try {
|
|
3190
|
+
const optimisticLockFilterList = expectedLastFireKey != null && expectedLastFireKey !== ""
|
|
3191
|
+
? [{ field: "details->>lastFireKey", operator: "eq", value: expectedLastFireKey }]
|
|
3192
|
+
: [];
|
|
3193
|
+
const data = await updateTargetDetails({
|
|
3194
|
+
id: triggerId,
|
|
3195
|
+
optimisticLockFilterList,
|
|
3196
|
+
updateFn: (current) => ({
|
|
3197
|
+
...current,
|
|
3198
|
+
lastFireKey: fireKey,
|
|
3199
|
+
lastFiredAt: now,
|
|
3200
|
+
}),
|
|
3201
|
+
});
|
|
3202
|
+
logger.success("Trigger 觸發記錄已更新", {
|
|
3203
|
+
topic: "trigger",
|
|
3204
|
+
data: { triggerId, fireKey },
|
|
2464
3205
|
});
|
|
3206
|
+
return generateResponse.success(data);
|
|
2465
3207
|
}
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
}),
|
|
2474
|
-
});
|
|
2475
|
-
logger.warn("心跳失敗,本輪跳過 runner", { topic: LOG_TOPIC_NODE });
|
|
2476
|
-
return;
|
|
2477
|
-
}
|
|
2478
|
-
await TriggerManager.tick(ctx);
|
|
3208
|
+
catch (error) {
|
|
3209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3210
|
+
logger.warn("Trigger 觸發記錄更新失敗", {
|
|
3211
|
+
topic: "trigger",
|
|
3212
|
+
data: { triggerId, fireKey, error: message },
|
|
3213
|
+
});
|
|
3214
|
+
return generateResponse.error(message);
|
|
2479
3215
|
}
|
|
2480
|
-
}
|
|
3216
|
+
});
|
|
2481
3217
|
|
|
2482
|
-
export { BaseNodeRuntime, CategoryCommand, CategoryRepo, CategoryTrigger, CommandType, LOG_TOPIC_TRIGGER, LogLevel, MAX_POLL_TARGET_LIST_SIZE, ManagedChildProcesses, NodeStatus, RepoManager, ResultCode, TASK_LOCAL_PACKAGE_CONFIG_FILENAME, TASK_REPO_SCRIPT_CATEGORY, TASK_REPO_USAGE, TASK_RUNNER_CONFIG_DIR, TASK_RUNNER_ROOT_CONFIG_FILENAME, TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH, TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH, TRIGGER_LOOP_INTERVAL_MS, TRIGGER_RUNNER_DEFAULT_RETRY_COUNT, TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS, TaskManager, TaskNode, TaskRepoValidation, TaskStatus, TaskStatusAction, TriggerManager, TriggerNode, TriggerStatus, buildNodeImportArgs, createLogger, createPathsExistCheck, createRequiredEnvCheck, createSupabaseReachableCheck, createTarget, generateResponse, getPollCommandList, getPollCommandListSchema, getPossibleTarget, getScanRemoteRepoValues, initSupabaseFromStandardEnv, isChildProcessRunning, isOptimisticLockResponse, loadEnvFiles, logManager, nodeBufferToArrayBuffer, patchChangeNodeStatus, patchChangeTaskStatus, patchClaimTask, patchNodeHeartBeat, patchStopNode, patchTriggerFired, patchTriggerFiredSchema, pollTargetList, postCommand, postCommandSchema, postRegisterNode, postTaskSchema, postTaskWithValidation, postTrigger, postTriggerSchema, publicBaseUrlFromEnv, readAndVerifySourceFile, readEnv, requireEnv, runReadinessChecks, scanEnabledTriggers, scanEnabledTriggersSchema, scanTargetList, sha256Hex, spawnTsxChild, supabase, updateTargetDetails, validateTaskRepoAndParams, validateWithSchema, waitForServiceReady };
|
|
3218
|
+
export { BaseNodeRuntime, CategoryCommand, CategoryNode, CategoryRepo, CategoryTrigger, CommandType, EMPTY_REGISTRY_SLOT_RUNTIME_STATE, LOG_TOPIC_TRIGGER, LogLevel, MAX_POLL_TARGET_LIST_SIZE, ManagedChildProcesses, NodeStatus, RepoManager, ResultCode, SERVICE_GUARD_RUNNER_KEY, ServiceGuardNode, ServiceRegistryError, TASK_LOCAL_PACKAGE_CONFIG_FILENAME, TASK_REPO_SCRIPT_CATEGORY, TASK_REPO_USAGE, TASK_RUNNER_CONFIG_DIR, TASK_RUNNER_ROOT_CONFIG_FILENAME, TASK_RUNNER_ROOT_CONFIG_LEGACY_RELATIVE_PATH, TASK_RUNNER_ROOT_CONFIG_RELATIVE_PATH, TRIGGER_LOOP_INTERVAL_MS, TRIGGER_RUNNER_DEFAULT_RETRY_COUNT, TRIGGER_RUNNER_DEFAULT_RETRY_DELAY_MS, TaskManager, TaskNode, TaskRepoValidation, TaskStatus, TaskStatusAction, TriggerManager, TriggerNode, TriggerStatus, applyRegistrySlotGuardStep, buildNodeImportArgs, claimServiceRegistrySlot, createJsonFileStateStore, createLogPersistCoordinator, createLogger, createManagedChildProcesses, createPathsExistCheck, createRequiredEnvCheck, createServiceHost, createServiceRuntimeStateStore, createSupabaseReachableCheck, createTarget, disableLogPersist, ensureLogPersistFromEnv, evaluateBusyNodeLiveness, generateResponse, getConfigFileDir, getLogPersistStats, getPollCommandList, getPollCommandListSchema, getPossibleTarget, getScanRemoteRepoValues, getWorkerSpawnCooldownLastAt, importJsConfigModule, initSupabaseFromStandardEnv, isChildProcessRunning, isOptimisticLockResponse, loadCachedJsConfigModule, loadEnvFiles, logManager, logPersistEnabledFromEnv, markWorkerSpawned, nodeBufferToArrayBuffer, patchChangeNodeStatus, patchChangeTaskStatus, patchClaimTask, patchNodeHeartBeat, patchServiceRuntime, patchStopNode, patchTriggerFired, patchTriggerFiredSchema, pathExists, pollTargetList, postCommand, postCommandSchema, postRegisterNode, postTaskSchema, postTaskWithValidation, postTrigger, postTriggerSchema, publicBaseUrlFromEnv, readAndVerifySourceFile, readEnv, registerServiceGuardRunner, registrySlotRuntimePatchFromGuardResult, requireEnv, resolveFirstExistingPath, resolvePathFromBaseDir, resolvePathFromConfigFile, resolvePathFromCwd, runReadinessChecks, runReadinessGate, runRegistrySlotGuardCheck, runServiceGuardTick, runSingleProcessService, scanEnabledTriggers, scanEnabledTriggersSchema, scanTargetList, sha256Hex, snapshotLogPersistReady, spawnTsxChild, supabase, toFileImportHref, updateTargetDetails, validateTaskRepoAndParams, validateWithSchema, waitForLogPersistReady, waitForServiceReady };
|
|
2483
3219
|
//# sourceMappingURL=node.js.map
|