@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.20
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/package.json +11 -9
- package/src/adapter/cloudflare/agent.ts +177 -122
- package/src/adapter/cloudflare/define-agent.ts +47 -26
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +15 -0
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +51 -13
- package/src/config/model-guidance.ts +20 -0
- package/src/config/pinning.ts +39 -5
- package/src/config/ports.ts +57 -7
- package/src/config/public-contracts.ts +2 -2
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +12 -0
- package/src/conversation/message/conversion.ts +3 -3
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +255 -446
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +12 -1
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/repository.ts +64 -25
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +317 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
|
@@ -17,7 +17,7 @@ import type { RuntimeState } from "../../conversation";
|
|
|
17
17
|
import type {
|
|
18
18
|
TemporaryAgentApprovalDecision,
|
|
19
19
|
TemporaryAgentApprovalRequest,
|
|
20
|
-
|
|
20
|
+
TemporaryAgentLaunchSelection,
|
|
21
21
|
} from "../../tools";
|
|
22
22
|
import { TEMPORARY_AGENT_LAUNCH_KEY } from "../../tools";
|
|
23
23
|
import {
|
|
@@ -83,7 +83,7 @@ export interface LoadedRuntime<Config> {
|
|
|
83
83
|
export interface RuntimeConfigUpdate<Change> {
|
|
84
84
|
readonly changed: boolean;
|
|
85
85
|
readonly change: Change;
|
|
86
|
-
/** Restore
|
|
86
|
+
/** Restore committed state if an immediate replacement fails. */
|
|
87
87
|
readonly rollback?: () => Promise<void>;
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -96,11 +96,16 @@ interface RuntimeAgentDefinitionBase<
|
|
|
96
96
|
/** 必须等于 Worker 导出名(Wrangler `class_name`)。 */
|
|
97
97
|
readonly name: string;
|
|
98
98
|
|
|
99
|
-
/** Refresh Host-owned inputs before a new Submission
|
|
99
|
+
/** Refresh Host-owned inputs before a new Submission records its Runtime key. */
|
|
100
100
|
readonly beforeSubmission?: (
|
|
101
101
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
102
102
|
) => Promise<boolean>;
|
|
103
103
|
|
|
104
|
+
/** Prepare per-Submission Host state after admission and before its first assembly pin. */
|
|
105
|
+
readonly afterSubmission?: (
|
|
106
|
+
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
107
|
+
) => Promise<void>;
|
|
108
|
+
|
|
104
109
|
readonly load: (
|
|
105
110
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
106
111
|
requestedRuntimeKey?: string,
|
|
@@ -114,7 +119,7 @@ interface RuntimeAgentDefinitionBase<
|
|
|
114
119
|
/** Prepare the optional telemetry consumer for this concrete Agent facet. */
|
|
115
120
|
readonly telemetry?: (
|
|
116
121
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
117
|
-
) => AgentTelemetryBinding | undefined
|
|
122
|
+
) => AgentTelemetryBinding | undefined | Promise<AgentTelemetryBinding | undefined>;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
export interface ReadonlyRuntimeAgentDefinition<
|
|
@@ -262,7 +267,12 @@ export interface RuntimeAgentControls {
|
|
|
262
267
|
*/
|
|
263
268
|
submitScheduledPrompt(
|
|
264
269
|
prompt: string,
|
|
265
|
-
opts?: {
|
|
270
|
+
opts?: {
|
|
271
|
+
idempotencyKey?: string;
|
|
272
|
+
scheduledMaxExecutionLevel?: import("../../lib/execution-level").ExecutionLevel;
|
|
273
|
+
/** @deprecated Retained for rolling upgrades; ignored by Runtime. */
|
|
274
|
+
unattendedExecutionGrant?: import("../../submission").RuntimeUnattendedExecutionGrant;
|
|
275
|
+
},
|
|
266
276
|
): Promise<SubmissionReceipt>;
|
|
267
277
|
/**
|
|
268
278
|
* 按提交编号查询当前处理状态。
|
|
@@ -355,9 +365,7 @@ const TERMINAL_AGENT_TOOL_STATUSES = [
|
|
|
355
365
|
] as const;
|
|
356
366
|
const AGENT_TOOL_RETENTION_MS = 24 * 60 * 60 * 1_000;
|
|
357
367
|
|
|
358
|
-
function parseTemporaryAgentLaunch
|
|
359
|
-
input: unknown,
|
|
360
|
-
): TemporaryAgentLaunch<Config> {
|
|
368
|
+
function parseTemporaryAgentLaunch(input: unknown): TemporaryAgentLaunchSelection {
|
|
361
369
|
const record = input != null && typeof input === "object"
|
|
362
370
|
? input as Record<string, unknown>
|
|
363
371
|
: {};
|
|
@@ -375,17 +383,15 @@ function parseTemporaryAgentLaunch<Config>(
|
|
|
375
383
|
) {
|
|
376
384
|
throw new Error("invalid temporary agent executionLevel");
|
|
377
385
|
}
|
|
378
|
-
|
|
379
|
-
throw new Error("invalid temporary agent config");
|
|
380
|
-
}
|
|
381
|
-
return {
|
|
386
|
+
const selection: TemporaryAgentLaunchSelection = {
|
|
382
387
|
subagentName: read("subagentName", 128),
|
|
383
388
|
instructions: read("instructions", 65_536),
|
|
384
389
|
task: read("task", 65_536),
|
|
385
390
|
runtimeKey: read("runtimeKey", 512),
|
|
386
|
-
config: record.config as Config,
|
|
387
391
|
executionLevel: executionLevel as ExecutionLevel,
|
|
388
392
|
};
|
|
393
|
+
if ("config" in record) throw new Error("unexpected temporary agent config");
|
|
394
|
+
return selection;
|
|
389
395
|
}
|
|
390
396
|
|
|
391
397
|
/**
|
|
@@ -406,14 +412,14 @@ export function defineRuntimeAgent<
|
|
|
406
412
|
| MutableRuntimeAgentDefinition<Env, Config, Command, Change>
|
|
407
413
|
| ReadonlyRuntimeAgentDefinition<Env, Config>,
|
|
408
414
|
): RuntimeAgentClass<Env, Command, Change> {
|
|
409
|
-
const resolveDefinitionTelemetry = (
|
|
415
|
+
const resolveDefinitionTelemetry = async (
|
|
410
416
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
411
|
-
): AgentTelemetryBinding | undefined => {
|
|
417
|
+
): Promise<AgentTelemetryBinding | undefined> => {
|
|
412
418
|
const telemetry = definition.telemetry;
|
|
413
419
|
if (!telemetry) return undefined;
|
|
414
|
-
return (telemetry as (
|
|
420
|
+
return await (telemetry as (
|
|
415
421
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
416
|
-
) => AgentTelemetryBinding | undefined)(context);
|
|
422
|
+
) => AgentTelemetryBinding | undefined | Promise<AgentTelemetryBinding | undefined>)(context);
|
|
417
423
|
};
|
|
418
424
|
|
|
419
425
|
const GeneratedRuntimeAgent = {
|
|
@@ -423,7 +429,7 @@ export function defineRuntimeAgent<
|
|
|
423
429
|
private loading?: Promise<void>;
|
|
424
430
|
private telemetryResolved = false;
|
|
425
431
|
private resolvedTelemetry?: AgentTelemetryBinding;
|
|
426
|
-
private temporaryLaunch?:
|
|
432
|
+
private temporaryLaunch?: TemporaryAgentLaunchSelection;
|
|
427
433
|
private temporaryDispose?: () => Promise<void>;
|
|
428
434
|
private temporaryCleanup?: Promise<void>;
|
|
429
435
|
|
|
@@ -444,10 +450,10 @@ export function defineRuntimeAgent<
|
|
|
444
450
|
return this.ensureConfig(runtimeKey);
|
|
445
451
|
}
|
|
446
452
|
|
|
447
|
-
protected async
|
|
453
|
+
protected async prepareRuntimeForAdmission(): Promise<void> {
|
|
448
454
|
const beforeSubmission = definition.beforeSubmission as RuntimeAgentDefinitionBase<Env, Config, Command, Change>["beforeSubmission"];
|
|
449
455
|
try {
|
|
450
|
-
const changed = await beforeSubmission?.(this.createDefinitionContext(
|
|
456
|
+
const changed = await beforeSubmission?.(this.createDefinitionContext());
|
|
451
457
|
await this.ensureConfig(undefined, changed === true);
|
|
452
458
|
} catch (error) {
|
|
453
459
|
this.hasLoadedRuntime = false;
|
|
@@ -455,6 +461,17 @@ export function defineRuntimeAgent<
|
|
|
455
461
|
}
|
|
456
462
|
}
|
|
457
463
|
|
|
464
|
+
protected async prepareRuntimeForSubmission(submissionId: string): Promise<void> {
|
|
465
|
+
const afterSubmission = definition.afterSubmission as RuntimeAgentDefinitionBase<Env, Config, Command, Change>["afterSubmission"];
|
|
466
|
+
try {
|
|
467
|
+
await this.ensureRuntimeReady();
|
|
468
|
+
await afterSubmission?.(this.createDefinitionContext(undefined, submissionId));
|
|
469
|
+
} catch (error) {
|
|
470
|
+
this.hasLoadedRuntime = false;
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
458
475
|
// 作用:加载默认 Runtime,或切换到调用方指定的 key。
|
|
459
476
|
// 调用:RPC 控制面在业务配置需要刷新时调用。
|
|
460
477
|
// 原因:复用 `ensureConfig`,让手动重载遵守和首次启动相同的原子规则。
|
|
@@ -546,7 +563,7 @@ export function defineRuntimeAgent<
|
|
|
546
563
|
if (this.role !== "temporary") {
|
|
547
564
|
throw new Error("Agent Tool runs require a temporary child facet");
|
|
548
565
|
}
|
|
549
|
-
const launch = parseTemporaryAgentLaunch
|
|
566
|
+
const launch = parseTemporaryAgentLaunch(input);
|
|
550
567
|
this.temporaryLaunch = launch;
|
|
551
568
|
await this.ctx.storage.put(TEMPORARY_AGENT_LAUNCH_KEY, launch);
|
|
552
569
|
await this.reloadRuntime(launch.runtimeKey, { force: true });
|
|
@@ -619,6 +636,11 @@ export function defineRuntimeAgent<
|
|
|
619
636
|
if (!resolvedRuntimeKey) {
|
|
620
637
|
throw new Error("Resolved Runtime key must not be empty");
|
|
621
638
|
}
|
|
639
|
+
if (requestedRuntimeKey && resolvedRuntimeKey !== requestedRuntimeKey) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Requested Runtime key ${requestedRuntimeKey} resolved to ${resolvedRuntimeKey}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
622
644
|
const context = this.createDefinitionContext({
|
|
623
645
|
value: loaded.config,
|
|
624
646
|
});
|
|
@@ -635,7 +657,7 @@ export function defineRuntimeAgent<
|
|
|
635
657
|
let resources = loaded.resources;
|
|
636
658
|
let commitGuard = loaded.commitGuard;
|
|
637
659
|
if (!this.telemetryResolved) {
|
|
638
|
-
this.resolvedTelemetry = resolveDefinitionTelemetry(context);
|
|
660
|
+
this.resolvedTelemetry = await resolveDefinitionTelemetry(context);
|
|
639
661
|
this.telemetryResolved = true;
|
|
640
662
|
}
|
|
641
663
|
const telemetry = this.resolvedTelemetry;
|
|
@@ -718,7 +740,7 @@ export function defineRuntimeAgent<
|
|
|
718
740
|
resources: ResolvedResources,
|
|
719
741
|
): Promise<ResolvedResources> {
|
|
720
742
|
const launch = this.temporaryLaunch ??
|
|
721
|
-
await this.ctx.storage.get<
|
|
743
|
+
await this.ctx.storage.get<TemporaryAgentLaunchSelection>(
|
|
722
744
|
TEMPORARY_AGENT_LAUNCH_KEY,
|
|
723
745
|
);
|
|
724
746
|
if (!launch) throw new Error("Temporary Agent is not initialized");
|
|
@@ -749,7 +771,7 @@ export function defineRuntimeAgent<
|
|
|
749
771
|
}
|
|
750
772
|
|
|
751
773
|
private async requestParentTemporaryApproval(
|
|
752
|
-
launch:
|
|
774
|
+
launch: TemporaryAgentLaunchSelection,
|
|
753
775
|
request: {
|
|
754
776
|
toolCallId: string;
|
|
755
777
|
toolName: string;
|
|
@@ -815,7 +837,7 @@ export function defineRuntimeAgent<
|
|
|
815
837
|
this.temporaryCleanup = (async () => {
|
|
816
838
|
if (!this.temporaryDispose) {
|
|
817
839
|
const launch = this.temporaryLaunch ??
|
|
818
|
-
await this.ctx.storage.get<
|
|
840
|
+
await this.ctx.storage.get<TemporaryAgentLaunchSelection>(
|
|
819
841
|
TEMPORARY_AGENT_LAUNCH_KEY,
|
|
820
842
|
);
|
|
821
843
|
if (launch) await this.ensureConfig(launch.runtimeKey);
|
|
@@ -885,7 +907,6 @@ export function defineRuntimeAgent<
|
|
|
885
907
|
input: {
|
|
886
908
|
...normalized,
|
|
887
909
|
runtimeKey: agent.loadedRuntimeKey,
|
|
888
|
-
config: runtimeConfig.value,
|
|
889
910
|
executionLevel: agent.executionLevel(),
|
|
890
911
|
},
|
|
891
912
|
runId: `temporary-agent:${runContext.toolCallId}`,
|
|
@@ -23,8 +23,26 @@ export {
|
|
|
23
23
|
export {
|
|
24
24
|
isValidTimeZone,
|
|
25
25
|
nextCronOccurrence,
|
|
26
|
+
wallTimeInstants,
|
|
26
27
|
zoneOffsetMs,
|
|
27
28
|
} from "./scheduling/zoned-cron";
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_LATENESS_WINDOW_MS,
|
|
31
|
+
isWithinLatenessWindow,
|
|
32
|
+
nextScheduledOccurrence,
|
|
33
|
+
resolveScheduleOccurrences,
|
|
34
|
+
toScheduleRuleSpec,
|
|
35
|
+
validateScheduleRuleSpec,
|
|
36
|
+
type LocalScheduleTrigger,
|
|
37
|
+
type ScheduledOccurrence,
|
|
38
|
+
type ScheduleOccurrence,
|
|
39
|
+
type ScheduleOccurrenceResolution,
|
|
40
|
+
type ScheduleRuleError,
|
|
41
|
+
type ScheduleRuleErrorCode,
|
|
42
|
+
type ScheduleRuleSpec,
|
|
43
|
+
type ScheduleSkipReason,
|
|
44
|
+
type SkippedOccurrence,
|
|
45
|
+
} from "./scheduling/schedule-rule";
|
|
28
46
|
|
|
29
47
|
export {
|
|
30
48
|
CloudflareSandboxAdapter,
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LocalScheduleRule,
|
|
3
|
+
ScheduleSpec,
|
|
4
|
+
} from "../../../submission/contracts";
|
|
5
|
+
import { isValidTimeZone, wallTimeInstants, zoneOffsetMs } from "./zoned-cron";
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 86_400_000;
|
|
8
|
+
const HOUR_MS = 3_600_000;
|
|
9
|
+
const MINUTE_MS = 60_000;
|
|
10
|
+
/** 扫描上限:每日/每周规则一定在 7 天内出现,这里只兜底防止坏输入空转。 */
|
|
11
|
+
const MAX_SCAN_DAYS = 366 * 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 宿主允许在计划时点之后多久仍可启动该轮次。宿主可覆盖,
|
|
15
|
+
* 通用模块只把它当作可参数化的迟到窗口,不承载套餐或任务策略。
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_LATENESS_WINDOW_MS = 5 * MINUTE_MS;
|
|
18
|
+
|
|
19
|
+
export type ScheduleSkipReason = "dst_missing_local_time";
|
|
20
|
+
|
|
21
|
+
/** 一个真实可执行的本地时间轮次。 */
|
|
22
|
+
export interface ScheduledOccurrence {
|
|
23
|
+
kind: "scheduled";
|
|
24
|
+
/** 跨重算、重启都稳定的轮次身份,供宿主做幂等键。 */
|
|
25
|
+
occurrenceId: string;
|
|
26
|
+
/** 计划执行的 UTC 瞬时。 */
|
|
27
|
+
scheduledAt: number;
|
|
28
|
+
/** 任务时区下的本地日期 `YYYY-MM-DD`。 */
|
|
29
|
+
localDate: string;
|
|
30
|
+
/** 任务时区下的本地钟点 `HH:mm`。 */
|
|
31
|
+
localTime: string;
|
|
32
|
+
/** 含边界的最迟可启动瞬时 = `scheduledAt + latenessWindowMs`。 */
|
|
33
|
+
latestStartAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 因夏令时缺失钟点而被跳过的轮次;没有可执行瞬时,只留身份与原因。 */
|
|
37
|
+
export interface SkippedOccurrence {
|
|
38
|
+
kind: "skipped";
|
|
39
|
+
occurrenceId: string;
|
|
40
|
+
localDate: string;
|
|
41
|
+
localTime: string;
|
|
42
|
+
reason: ScheduleSkipReason;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ScheduleOccurrence = ScheduledOccurrence | SkippedOccurrence;
|
|
46
|
+
|
|
47
|
+
/** 一条按任务时区解释的当地时间规则及其生效窗口。 */
|
|
48
|
+
export interface ScheduleRuleSpec {
|
|
49
|
+
rule: LocalScheduleRule;
|
|
50
|
+
/** IANA 时区名,如 `Asia/Shanghai`;绝不退化成固定 UTC offset。 */
|
|
51
|
+
timeZone: string;
|
|
52
|
+
/** 含当天的最早生效本地日期 `YYYY-MM-DD`。 */
|
|
53
|
+
startDate?: string;
|
|
54
|
+
/** 含当天的最后生效本地日期 `YYYY-MM-DD`。 */
|
|
55
|
+
endDate?: string;
|
|
56
|
+
/** 覆盖默认迟到窗口。 */
|
|
57
|
+
latenessWindowMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `ScheduleSpec.trigger` 里 `local` 变体的形状。 */
|
|
61
|
+
export type LocalScheduleTrigger = Extract<
|
|
62
|
+
ScheduleSpec["trigger"],
|
|
63
|
+
{ kind: "local" }
|
|
64
|
+
>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 把合同的 `local` 触发条件投影成时间规则计算规格。
|
|
68
|
+
*
|
|
69
|
+
* 宿主与路由共用同一映射,避免各自拼一份 `startDate`/`endDate`,改合同字段时漏改。
|
|
70
|
+
*/
|
|
71
|
+
export function toScheduleRuleSpec(
|
|
72
|
+
trigger: LocalScheduleTrigger,
|
|
73
|
+
timeZone: string,
|
|
74
|
+
): ScheduleRuleSpec {
|
|
75
|
+
return {
|
|
76
|
+
rule: trigger.rule,
|
|
77
|
+
timeZone,
|
|
78
|
+
...(trigger.startDate ? { startDate: trigger.startDate } : {}),
|
|
79
|
+
...(trigger.endDate ? { endDate: trigger.endDate } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ScheduleRuleErrorCode =
|
|
84
|
+
| "invalid_rule"
|
|
85
|
+
| "invalid_time_zone"
|
|
86
|
+
| "invalid_date"
|
|
87
|
+
| "invalid_time"
|
|
88
|
+
| "empty_weekdays"
|
|
89
|
+
| "invalid_weekday"
|
|
90
|
+
| "invalid_window"
|
|
91
|
+
| "invalid_limit"
|
|
92
|
+
| "no_future_occurrence";
|
|
93
|
+
|
|
94
|
+
export interface ScheduleRuleError {
|
|
95
|
+
code: ScheduleRuleErrorCode;
|
|
96
|
+
message: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ScheduleOccurrenceResolution =
|
|
100
|
+
| { ok: true; occurrences: ScheduleOccurrence[] }
|
|
101
|
+
| { ok: false; error: ScheduleRuleError };
|
|
102
|
+
|
|
103
|
+
interface LocalClock {
|
|
104
|
+
hour: number;
|
|
105
|
+
minute: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 校验规则形状与时区,不判断是否存在未来时点。 */
|
|
109
|
+
export function validateScheduleRuleSpec(
|
|
110
|
+
spec: ScheduleRuleSpec,
|
|
111
|
+
): ScheduleRuleError | null {
|
|
112
|
+
if (typeof spec.timeZone !== "string" || !isValidTimeZone(spec.timeZone)) {
|
|
113
|
+
return { code: "invalid_time_zone", message: `未知时区:${spec.timeZone}` };
|
|
114
|
+
}
|
|
115
|
+
const rule = spec.rule as unknown;
|
|
116
|
+
if (!isRecord(rule) ||
|
|
117
|
+
(rule.kind !== "once" && rule.kind !== "daily" && rule.kind !== "weekly")) {
|
|
118
|
+
return { code: "invalid_rule", message: "不支持的当地时间规则" };
|
|
119
|
+
}
|
|
120
|
+
const time = typeof rule.time === "string" ? parseTime(rule.time) : null;
|
|
121
|
+
if (!time) {
|
|
122
|
+
return { code: "invalid_time", message: `非法钟点:${String(rule.time ?? "")}` };
|
|
123
|
+
}
|
|
124
|
+
if (rule.kind === "once" &&
|
|
125
|
+
(typeof rule.date !== "string" || parseDate(rule.date) === null)) {
|
|
126
|
+
return { code: "invalid_date", message: `非法日期:${String(rule.date ?? "")}` };
|
|
127
|
+
}
|
|
128
|
+
if (rule.kind === "weekly") {
|
|
129
|
+
if (!Array.isArray(rule.weekdays) || rule.weekdays.length === 0) {
|
|
130
|
+
if (Array.isArray(rule.weekdays)) {
|
|
131
|
+
return { code: "empty_weekdays", message: "每周规则至少需要一个星期" };
|
|
132
|
+
}
|
|
133
|
+
return { code: "invalid_weekday", message: "星期必须是数字数组" };
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
rule.weekdays.some(
|
|
137
|
+
(weekday) => !Number.isInteger(weekday) || weekday < 0 || weekday > 6,
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
return {
|
|
141
|
+
code: "invalid_weekday",
|
|
142
|
+
message: `非法星期:${rule.weekdays.join(",")}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const start = optionalDate(spec.startDate);
|
|
147
|
+
if (spec.startDate !== undefined && start === null) {
|
|
148
|
+
return { code: "invalid_date", message: `非法开始日期:${spec.startDate}` };
|
|
149
|
+
}
|
|
150
|
+
const end = optionalDate(spec.endDate);
|
|
151
|
+
if (spec.endDate !== undefined && end === null) {
|
|
152
|
+
return { code: "invalid_date", message: `非法结束日期:${spec.endDate}` };
|
|
153
|
+
}
|
|
154
|
+
if (start !== null && end !== null && end < start) {
|
|
155
|
+
return { code: "invalid_window", message: "结束日期不能早于开始日期" };
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 从 `after`(UTC 瞬时,不含)起枚举轮次,含被跳过的缺失钟点。
|
|
162
|
+
*
|
|
163
|
+
* 预览、下一轮计算和恢复枚举都走这里,保证三者语义一致;输出为空时返回可识别的
|
|
164
|
+
* `no_future_occurrence`,而不是伪造一个执行时间。
|
|
165
|
+
*/
|
|
166
|
+
export function resolveScheduleOccurrences(
|
|
167
|
+
spec: ScheduleRuleSpec,
|
|
168
|
+
options: { after: number; limit: number },
|
|
169
|
+
): ScheduleOccurrenceResolution {
|
|
170
|
+
const error = validateScheduleRuleSpec(spec);
|
|
171
|
+
if (error) return { ok: false, error };
|
|
172
|
+
if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: { code: "invalid_limit", message: "轮次数量必须是正整数" },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const occurrences = enumerate(spec, options.after, options.limit);
|
|
179
|
+
if (occurrences.length === 0) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: {
|
|
183
|
+
code: "no_future_occurrence",
|
|
184
|
+
message: "该规则在给定时间之后没有有效执行时点",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, occurrences };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 下一个可执行瞬时;缺失钟点被跳过,不伪造、不顺延补跑。 */
|
|
192
|
+
export function nextScheduledOccurrence(
|
|
193
|
+
spec: ScheduleRuleSpec,
|
|
194
|
+
after: number,
|
|
195
|
+
): ScheduledOccurrence | null {
|
|
196
|
+
if (validateScheduleRuleSpec(spec) !== null) return null;
|
|
197
|
+
for (let limit = 1; limit <= 512; limit *= 2) {
|
|
198
|
+
const occurrences = enumerate(spec, after, limit);
|
|
199
|
+
const scheduled = occurrences.find(
|
|
200
|
+
(occurrence): occurrence is ScheduledOccurrence =>
|
|
201
|
+
occurrence.kind === "scheduled",
|
|
202
|
+
);
|
|
203
|
+
if (scheduled) return scheduled;
|
|
204
|
+
if (occurrences.length < limit) return null;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 含边界判断某个已计划轮次是否仍在迟到窗口内。 */
|
|
210
|
+
export function isWithinLatenessWindow(
|
|
211
|
+
occurrence: ScheduledOccurrence,
|
|
212
|
+
now: number,
|
|
213
|
+
): boolean {
|
|
214
|
+
return now >= occurrence.scheduledAt && now <= occurrence.latestStartAt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function enumerate(
|
|
218
|
+
spec: ScheduleRuleSpec,
|
|
219
|
+
after: number,
|
|
220
|
+
limit: number,
|
|
221
|
+
): ScheduleOccurrence[] {
|
|
222
|
+
const time = parseTime(spec.rule.time);
|
|
223
|
+
if (!time) return [];
|
|
224
|
+
const windowMs = spec.latenessWindowMs ?? DEFAULT_LATENESS_WINDOW_MS;
|
|
225
|
+
const wallAfter = after + zoneOffsetMs(after, spec.timeZone);
|
|
226
|
+
const dayOfAfter = Math.floor(wallAfter / DAY_MS) * DAY_MS;
|
|
227
|
+
const startDay = optionalDate(spec.startDate) ?? dayOfAfter;
|
|
228
|
+
const endDay = optionalDate(spec.endDate);
|
|
229
|
+
const occurrences: ScheduleOccurrence[] = [];
|
|
230
|
+
|
|
231
|
+
const onceDay = spec.rule.kind === "once" ? parseDate(spec.rule.date) : null;
|
|
232
|
+
// 一次性规则直接跳到目标日期,不能让重复规则的扫描保险上限拒绝远期任务。
|
|
233
|
+
let day = Math.max(startDay, dayOfAfter, onceDay ?? Number.NEGATIVE_INFINITY);
|
|
234
|
+
const maxScanDays = onceDay === null ? MAX_SCAN_DAYS : 0;
|
|
235
|
+
for (let scanned = 0; scanned <= maxScanDays; scanned += 1, day += DAY_MS) {
|
|
236
|
+
if (endDay !== null && day > endDay) break;
|
|
237
|
+
if (!matchesRule(spec.rule, day)) continue;
|
|
238
|
+
|
|
239
|
+
const wall = day + time.hour * HOUR_MS + time.minute * MINUTE_MS;
|
|
240
|
+
const localDate = formatDate(day);
|
|
241
|
+
const localTime = formatTime(time);
|
|
242
|
+
const occurrenceId = `${localDate}T${localTime}`;
|
|
243
|
+
const instants = wallTimeInstants(wall, spec.timeZone);
|
|
244
|
+
if (instants.length === 0) {
|
|
245
|
+
// 拨快抹掉的钟点:没有可执行瞬时,只在游标之后才作为跳过轮次报告。
|
|
246
|
+
if (wall <= wallAfter) continue;
|
|
247
|
+
occurrences.push({
|
|
248
|
+
kind: "skipped",
|
|
249
|
+
occurrenceId,
|
|
250
|
+
localDate,
|
|
251
|
+
localTime,
|
|
252
|
+
reason: "dst_missing_local_time",
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
const scheduledAt = instants[0];
|
|
256
|
+
if (scheduledAt <= after) continue;
|
|
257
|
+
occurrences.push({
|
|
258
|
+
kind: "scheduled",
|
|
259
|
+
occurrenceId,
|
|
260
|
+
scheduledAt,
|
|
261
|
+
localDate,
|
|
262
|
+
localTime,
|
|
263
|
+
latestStartAt: scheduledAt + windowMs,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (occurrences.length >= limit) break;
|
|
267
|
+
}
|
|
268
|
+
return occurrences;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function matchesRule(rule: LocalScheduleRule, day: number): boolean {
|
|
272
|
+
switch (rule.kind) {
|
|
273
|
+
case "once":
|
|
274
|
+
return day === parseDate(rule.date);
|
|
275
|
+
case "daily":
|
|
276
|
+
return true;
|
|
277
|
+
case "weekly":
|
|
278
|
+
return rule.weekdays.includes(new Date(day).getUTCDay());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseTime(value: string): LocalClock | null {
|
|
283
|
+
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
284
|
+
if (!match) return null;
|
|
285
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function formatTime({ hour, minute }: LocalClock): string {
|
|
289
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** `YYYY-MM-DD` → UTC 零点毫秒;非法日历日期返回 null。 */
|
|
293
|
+
function parseDate(value: string): number | null {
|
|
294
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
295
|
+
if (!match) return null;
|
|
296
|
+
const year = Number(match[1]);
|
|
297
|
+
const month = Number(match[2]);
|
|
298
|
+
const day = Number(match[3]);
|
|
299
|
+
const ms = Date.UTC(year, month - 1, day);
|
|
300
|
+
const date = new Date(ms);
|
|
301
|
+
if (
|
|
302
|
+
date.getUTCFullYear() !== year ||
|
|
303
|
+
date.getUTCMonth() !== month - 1 ||
|
|
304
|
+
date.getUTCDate() !== day
|
|
305
|
+
) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return ms;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function optionalDate(value: string | undefined): number | null {
|
|
312
|
+
if (value === undefined) return null;
|
|
313
|
+
return parseDate(value);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function formatDate(day: number): string {
|
|
317
|
+
return new Date(day).toISOString().slice(0, 10);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
321
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
322
|
+
}
|
|
@@ -55,7 +55,8 @@ export function nextCronOccurrence(
|
|
|
55
55
|
for (const second of parsed.seconds) {
|
|
56
56
|
const wall = day + hour * 3_600_000 + minute * 60_000 + second * 1_000;
|
|
57
57
|
if (wall <= wallAfter) continue;
|
|
58
|
-
const resolved =
|
|
58
|
+
const resolved = wallTimeInstants(wall, timeZone)
|
|
59
|
+
.find((instant) => instant > after);
|
|
59
60
|
if (resolved !== undefined) return resolved;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
@@ -73,18 +74,22 @@ export function isValidTimeZone(timeZone: string): boolean {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
/**
|
|
78
|
+
* 所有本地墙上时间恰好等于 `wallUtc` 的 UTC 瞬时,升序。
|
|
79
|
+
*
|
|
80
|
+
* `wallUtc` 是"把本地钟点当作 UTC"的伪瞬时(见 {@link zoneOffsetMs})。
|
|
81
|
+
* 返回空数组 = 该钟点在当地被夏令时拨快抹掉;返回两个 = 拨慢导致同一钟点出现两次。
|
|
82
|
+
* 这是 `nextCronOccurrence` 与本地规则计算共用的唯一钟点解析边界。
|
|
83
|
+
*/
|
|
84
|
+
export function wallTimeInstants(wallUtc: number, timeZone: string): number[] {
|
|
77
85
|
const offsets = new Set([
|
|
78
|
-
zoneOffsetMs(after, timeZone),
|
|
79
86
|
zoneOffsetMs(wallUtc - 2 * 86_400_000, timeZone),
|
|
80
87
|
zoneOffsetMs(wallUtc, timeZone),
|
|
81
88
|
zoneOffsetMs(wallUtc + 2 * 86_400_000, timeZone),
|
|
82
89
|
]);
|
|
83
90
|
return [...offsets]
|
|
84
91
|
.map((offset) => wallUtc - offset)
|
|
85
|
-
.filter((instant) =>
|
|
86
|
-
instant > after && instant + zoneOffsetMs(instant, timeZone) === wallUtc
|
|
87
|
-
)
|
|
92
|
+
.filter((instant) => instant + zoneOffsetMs(instant, timeZone) === wallUtc)
|
|
88
93
|
.sort((left, right) => left - right);
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -51,6 +51,18 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
51
51
|
if (!submissionColumns.has("regenerate_message_id")) {
|
|
52
52
|
sql`ALTER TABLE pi_submissions ADD COLUMN regenerate_message_id TEXT`;
|
|
53
53
|
}
|
|
54
|
+
if (!submissionColumns.has("origin")) {
|
|
55
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'`;
|
|
56
|
+
}
|
|
57
|
+
if (!submissionColumns.has("scheduled_max_execution_level")) {
|
|
58
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN scheduled_max_execution_level TEXT`;
|
|
59
|
+
}
|
|
60
|
+
if (!submissionColumns.has("unattended_execution_grant_json")) {
|
|
61
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN unattended_execution_grant_json TEXT`;
|
|
62
|
+
}
|
|
63
|
+
if (!submissionColumns.has("unattended_failure")) {
|
|
64
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN unattended_failure TEXT`;
|
|
65
|
+
}
|
|
54
66
|
sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
|
|
55
67
|
ON pi_submissions(status)
|
|
56
68
|
WHERE status = 'running'`;
|
|
@@ -156,6 +168,9 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
156
168
|
.map((column) => column.name),
|
|
157
169
|
);
|
|
158
170
|
let columns = approvalColumns();
|
|
171
|
+
if (!columns.has("confirmation_json")) {
|
|
172
|
+
sql`ALTER TABLE pi_approvals ADD COLUMN confirmation_json TEXT`;
|
|
173
|
+
}
|
|
159
174
|
if (!columns.has("execution_level")) {
|
|
160
175
|
// 旧 Session 没有持久化当时的授权档位,safe 失败关闭;只影响旧 pending 回执展示,不会静默放行。
|
|
161
176
|
sql`ALTER TABLE pi_approvals
|