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