@haaaiawd/second-nature 0.2.4 → 0.2.6
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/runtime/cli/commands/index.d.ts +4 -0
- package/runtime/cli/commands/index.js +179 -5
- package/runtime/cli/index.js +2 -0
- package/runtime/cli/ops/ops-router.js +27 -17
- package/runtime/connectors/base/contract.d.ts +1 -0
- package/runtime/connectors/base/failure-taxonomy.js +45 -26
- package/runtime/connectors/services/connector-cooldown-port.d.ts +22 -0
- package/runtime/connectors/services/connector-cooldown-port.js +123 -0
- package/runtime/connectors/services/connector-executor-adapter.js +10 -4
- package/runtime/connectors/services/credential-route-context.d.ts +3 -2
- package/runtime/connectors/services/credential-route-context.js +19 -3
- package/runtime/core/second-nature/action/action-closure-recorder.d.ts +4 -0
- package/runtime/core/second-nature/action/action-closure-recorder.js +5 -0
- package/runtime/core/second-nature/action/action-proposal-builder.js +1 -0
- package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.d.ts +2 -0
- package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.js +76 -0
- package/runtime/core/second-nature/control-plane/real-runtime-spine.d.ts +2 -0
- package/runtime/core/second-nature/control-plane/real-runtime-spine.js +1 -0
- package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.d.ts +1 -1
- package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.js +10 -5
- package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.d.ts +2 -2
- package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.js +10 -28
- package/runtime/observability/db/index.d.ts +2 -0
- package/runtime/observability/db/index.js +6 -0
- package/runtime/observability/living-loop-health-gate.d.ts +6 -2
- package/runtime/observability/living-loop-health-gate.js +52 -5
- package/runtime/observability/loop-status.d.ts +19 -0
- package/runtime/observability/loop-status.js +121 -7
- package/runtime/observability/services/heartbeat-digest-assembler.d.ts +9 -0
- package/runtime/observability/services/heartbeat-digest-assembler.js +44 -9
- package/runtime/shared/types/v8-contracts.d.ts +1 -1
- package/runtime/storage/db/index.d.ts +2 -0
- package/runtime/storage/db/index.js +28 -2
- package/runtime/storage/db/schema/v8-entities.d.ts +288 -0
- package/runtime/storage/db/schema/v8-entities.js +23 -1
- package/runtime/storage/v8-state-stores.d.ts +10 -1
- package/runtime/storage/v8-state-stores.js +86 -1
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "second-nature",
|
|
3
3
|
"name": "Second Nature",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.6",
|
|
5
5
|
"description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace. Agent inner guide is packaged as agent-inner-guide.md. v7 ops surface: self_health, tool_affordance, heartbeat_digest, snapshot:capture, narrative:diff, timeline, restore, runtime_secret_bootstrap, connector:run, guidance_payload.",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onStartup": true,
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ActionBridge } from "../action-bridge.js";
|
|
2
2
|
import type { OpsRouter } from "../ops/ops-router.js";
|
|
3
|
+
import type { StateDatabase } from "../../storage/db/index.js";
|
|
4
|
+
import type { ObservabilityDatabase } from "../../observability/db/index.js";
|
|
3
5
|
import type { CliReadModels } from "../read-models/index.js";
|
|
4
6
|
export interface CliCommandDefinition {
|
|
5
7
|
name: string;
|
|
@@ -10,5 +12,7 @@ export interface CliCommandDeps {
|
|
|
10
12
|
readModels: CliReadModels;
|
|
11
13
|
actionBridge: ActionBridge;
|
|
12
14
|
opsRouter: OpsRouter;
|
|
15
|
+
stateDb?: StateDatabase;
|
|
16
|
+
observabilityDb?: ObservabilityDatabase;
|
|
13
17
|
}
|
|
14
18
|
export declare function createCliCommands(deps: CliCommandDeps): CliCommandDefinition[];
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { credentialVerify } from "./credential.js";
|
|
2
4
|
import { connectorInit } from "./connector-init.js";
|
|
3
5
|
import { formatExplanation } from "../explain/format-explanation.js";
|
|
@@ -5,6 +7,139 @@ import { explainSurfaceSubject } from "../explain/explain-surface-subject.js";
|
|
|
5
7
|
import { showOperatorFallback, OperatorFallbackNotFoundError, } from "../ops/show-operator-fallback.js";
|
|
6
8
|
import { runStorageModeSmoke } from "../../storage/bootstrap/storage-mode-smoke.js";
|
|
7
9
|
import { policySet } from "./policy.js";
|
|
10
|
+
const SETUP_MARKER_RELATIVE_PATH = path.join(".second-nature", "setup", "agent-inner-guide-ack.json");
|
|
11
|
+
function safeShortText(value, maxLen) {
|
|
12
|
+
if (typeof value !== "string")
|
|
13
|
+
return undefined;
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
if (trimmed.length === 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
return trimmed.slice(0, maxLen);
|
|
18
|
+
}
|
|
19
|
+
function resolveWorkspaceRoot(input) {
|
|
20
|
+
if (typeof input?.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0) {
|
|
21
|
+
return path.resolve(input.workspaceRoot);
|
|
22
|
+
}
|
|
23
|
+
if (typeof process.env.SECOND_NATURE_WORKSPACE_ROOT === "string" && process.env.SECOND_NATURE_WORKSPACE_ROOT.trim().length > 0) {
|
|
24
|
+
return path.resolve(process.env.SECOND_NATURE_WORKSPACE_ROOT);
|
|
25
|
+
}
|
|
26
|
+
return process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function readSetupText(fileName) {
|
|
29
|
+
const candidates = fileName === "SKILL.md"
|
|
30
|
+
? [path.resolve(process.cwd(), "SKILL.md"), path.resolve(process.cwd(), "..", "SKILL.md")]
|
|
31
|
+
: [path.resolve(process.cwd(), "plugin", "agent-inner-guide.md"), path.resolve(process.cwd(), "..", "plugin", "agent-inner-guide.md")];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
const content = fs.readFileSync(candidate, "utf-8");
|
|
35
|
+
return { ok: true, path: candidate, content };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// try next candidate
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, path: candidates[0] ?? fileName, error: `Could not read ${fileName}` };
|
|
42
|
+
}
|
|
43
|
+
function summarizeSetupText(content) {
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
const nonEmpty = lines.filter((line) => line.trim().length > 0);
|
|
46
|
+
const first = nonEmpty.slice(0, 3).join("\n");
|
|
47
|
+
const marker = content.length > first.length ? "\n\n[...]" : "";
|
|
48
|
+
return `${first}${marker}`;
|
|
49
|
+
}
|
|
50
|
+
function readSetupAckMarker(workspaceRoot) {
|
|
51
|
+
const markerPath = path.join(workspaceRoot, SETUP_MARKER_RELATIVE_PATH);
|
|
52
|
+
try {
|
|
53
|
+
const raw = fs.readFileSync(markerPath, "utf-8");
|
|
54
|
+
const marker = JSON.parse(raw);
|
|
55
|
+
if (marker.status === "acknowledged") {
|
|
56
|
+
return {
|
|
57
|
+
status: "acknowledged",
|
|
58
|
+
markerPath,
|
|
59
|
+
acknowledgedAt: typeof marker.acknowledgedAt === "string" ? marker.acknowledgedAt : undefined,
|
|
60
|
+
placedIn: typeof marker.placedIn === "string" ? marker.placedIn : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// marker missing or unreadable
|
|
66
|
+
}
|
|
67
|
+
return { status: "pending", markerPath };
|
|
68
|
+
}
|
|
69
|
+
async function buildSetupHintPayload(input) {
|
|
70
|
+
const format = input?.format === "full" ? "full" : "summary";
|
|
71
|
+
const includeSkill = input?.includeSkill !== false;
|
|
72
|
+
const includeGuide = input?.includeGuide !== false;
|
|
73
|
+
const workspaceRoot = resolveWorkspaceRoot(input);
|
|
74
|
+
const ack = readSetupAckMarker(workspaceRoot);
|
|
75
|
+
const data = {
|
|
76
|
+
status: ack.status,
|
|
77
|
+
workspaceRoot,
|
|
78
|
+
markerPath: ack.markerPath,
|
|
79
|
+
acknowledgedAt: ack.acknowledgedAt,
|
|
80
|
+
placedIn: ack.placedIn,
|
|
81
|
+
recommendedPlacement: [
|
|
82
|
+
"agent prompt",
|
|
83
|
+
"workspace/IDENTITY.md",
|
|
84
|
+
"workspace/USER.md",
|
|
85
|
+
],
|
|
86
|
+
nextStep: ack.status === "acknowledged"
|
|
87
|
+
? "setup_already_acknowledged"
|
|
88
|
+
: "read_returned_guidance_then_run_setup_ack",
|
|
89
|
+
};
|
|
90
|
+
if (includeSkill) {
|
|
91
|
+
const skill = readSetupText("SKILL.md");
|
|
92
|
+
data.skill = skill.ok
|
|
93
|
+
? {
|
|
94
|
+
path: skill.path,
|
|
95
|
+
content: format === "full" ? skill.content : summarizeSetupText(skill.content),
|
|
96
|
+
}
|
|
97
|
+
: skill;
|
|
98
|
+
}
|
|
99
|
+
if (includeGuide) {
|
|
100
|
+
const guide = readSetupText("agent-inner-guide.md");
|
|
101
|
+
data.guide = guide.ok
|
|
102
|
+
? {
|
|
103
|
+
path: guide.path,
|
|
104
|
+
content: format === "full" ? guide.content : summarizeSetupText(guide.content),
|
|
105
|
+
}
|
|
106
|
+
: guide;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
command: "setup_hint",
|
|
111
|
+
surfaceMode: "workspace_full_runtime",
|
|
112
|
+
message: "Read the SKILL and guide as a friendly setup note, then place the guidance where the agent naturally checks its working anchors.",
|
|
113
|
+
data,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function buildSetupAckPayload(input) {
|
|
117
|
+
const workspaceRoot = resolveWorkspaceRoot(input);
|
|
118
|
+
const markerPath = path.join(workspaceRoot, SETUP_MARKER_RELATIVE_PATH);
|
|
119
|
+
const marker = {
|
|
120
|
+
acknowledgedAt: new Date().toISOString(),
|
|
121
|
+
acceptedBy: safeShortText(input?.acceptedBy, 80) ?? "agent",
|
|
122
|
+
placedIn: safeShortText(input?.placedIn, 160) ?? "unspecified",
|
|
123
|
+
note: safeShortText(input?.note, 240),
|
|
124
|
+
guideVersion: "0.2.5",
|
|
125
|
+
source: "second-nature-cli",
|
|
126
|
+
skillPath: "SKILL.md",
|
|
127
|
+
guidePath: "plugin/agent-inner-guide.md",
|
|
128
|
+
status: "acknowledged",
|
|
129
|
+
};
|
|
130
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
command: "setup_ack",
|
|
135
|
+
surfaceMode: "workspace_full_runtime",
|
|
136
|
+
message: "Setup guide acknowledgement persisted; setup nudge is now silent for this workspace.",
|
|
137
|
+
data: {
|
|
138
|
+
markerPath,
|
|
139
|
+
...marker,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
8
143
|
const notImplemented = async (command) => ({
|
|
9
144
|
ok: false,
|
|
10
145
|
command,
|
|
@@ -22,16 +157,45 @@ function explainSubjectError(code, message) {
|
|
|
22
157
|
};
|
|
23
158
|
}
|
|
24
159
|
export function createCliCommands(deps) {
|
|
25
|
-
const { readModels, actionBridge, opsRouter } = deps;
|
|
160
|
+
const { readModels, actionBridge, opsRouter, stateDb, observabilityDb } = deps;
|
|
161
|
+
const flush = () => {
|
|
162
|
+
try {
|
|
163
|
+
stateDb?.flush();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// ignore flush errors to avoid masking command results
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
observabilityDb?.flush();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// ignore flush errors to avoid masking command results
|
|
173
|
+
}
|
|
174
|
+
};
|
|
26
175
|
const opsCommand = (name, description) => ({
|
|
27
176
|
name,
|
|
28
177
|
description,
|
|
29
178
|
execute: async (input) => {
|
|
30
179
|
const surface = await Promise.resolve(opsRouter.dispatch(name, input));
|
|
180
|
+
flush();
|
|
31
181
|
return surface;
|
|
32
182
|
},
|
|
33
183
|
});
|
|
34
184
|
return [
|
|
185
|
+
{
|
|
186
|
+
name: "setup_hint",
|
|
187
|
+
description: "Return the packaged setup SKILL and agent inner guide for first-run onboarding",
|
|
188
|
+
execute: async (input) => buildSetupHintPayload(input),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "setup_ack",
|
|
192
|
+
description: "Persist that the packaged setup guide was read and placed into working anchors",
|
|
193
|
+
execute: async (input) => {
|
|
194
|
+
const result = await buildSetupAckPayload(input);
|
|
195
|
+
flush();
|
|
196
|
+
return result;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
35
199
|
{
|
|
36
200
|
name: "status",
|
|
37
201
|
description: "T1.2.6 — Show v6 aggregated Second Nature status (narrative + dream + cycles + runtime)",
|
|
@@ -47,7 +211,9 @@ export function createCliCommands(deps) {
|
|
|
47
211
|
execute: async (input) => {
|
|
48
212
|
const action = typeof input?.action === "string" ? input.action : "show";
|
|
49
213
|
if (action === "set") {
|
|
50
|
-
|
|
214
|
+
const result = await policySet(actionBridge, input);
|
|
215
|
+
flush();
|
|
216
|
+
return result;
|
|
51
217
|
}
|
|
52
218
|
// T1.2.6 (SN-CODE-01): `policy show` (default) returns the current rhythm policy
|
|
53
219
|
// snapshot. Returns workspace defaults when no policy row has been persisted yet.
|
|
@@ -159,6 +325,7 @@ export function createCliCommands(deps) {
|
|
|
159
325
|
description: "Workspace heartbeat_check ops surface (v5 HeartbeatSurfaceResult)",
|
|
160
326
|
execute: async (input) => {
|
|
161
327
|
const surface = await Promise.resolve(opsRouter.dispatch("heartbeat_check", input));
|
|
328
|
+
flush();
|
|
162
329
|
return surface;
|
|
163
330
|
},
|
|
164
331
|
},
|
|
@@ -174,6 +341,7 @@ export function createCliCommands(deps) {
|
|
|
174
341
|
runRepairFixture,
|
|
175
342
|
workspaceRoot,
|
|
176
343
|
});
|
|
344
|
+
flush();
|
|
177
345
|
return { ok: true, data };
|
|
178
346
|
},
|
|
179
347
|
},
|
|
@@ -218,6 +386,7 @@ export function createCliCommands(deps) {
|
|
|
218
386
|
description: "T1.2.8 — probe host capabilities and persist report (static unknown adapter in CLI context)",
|
|
219
387
|
execute: async (input) => {
|
|
220
388
|
const surface = await Promise.resolve(opsRouter.dispatch("capability_probe", input));
|
|
389
|
+
flush();
|
|
221
390
|
return surface;
|
|
222
391
|
},
|
|
223
392
|
},
|
|
@@ -226,6 +395,7 @@ export function createCliCommands(deps) {
|
|
|
226
395
|
description: "T3.3.2 — run near-real connector smoke (sentinel Moltbook + EvoMap, no live HTTP)",
|
|
227
396
|
execute: async (input) => {
|
|
228
397
|
const surface = await Promise.resolve(opsRouter.dispatch("near_real_smoke", input));
|
|
398
|
+
flush();
|
|
229
399
|
return surface;
|
|
230
400
|
},
|
|
231
401
|
},
|
|
@@ -249,6 +419,7 @@ export function createCliCommands(deps) {
|
|
|
249
419
|
? input.workspaceRoot
|
|
250
420
|
: undefined,
|
|
251
421
|
});
|
|
422
|
+
flush();
|
|
252
423
|
return result;
|
|
253
424
|
},
|
|
254
425
|
},
|
|
@@ -261,10 +432,11 @@ export function createCliCommands(deps) {
|
|
|
261
432
|
},
|
|
262
433
|
},
|
|
263
434
|
{
|
|
264
|
-
name: "
|
|
265
|
-
description: "T1.
|
|
435
|
+
name: "credential",
|
|
436
|
+
description: "T1.4.1 — inspect or verify credential health without exposing plaintext",
|
|
266
437
|
execute: async (input) => {
|
|
267
|
-
const surface = await Promise.resolve(opsRouter.dispatch("
|
|
438
|
+
const surface = await Promise.resolve(opsRouter.dispatch("credential", input));
|
|
439
|
+
flush();
|
|
268
440
|
return surface;
|
|
269
441
|
},
|
|
270
442
|
},
|
|
@@ -273,6 +445,7 @@ export function createCliCommands(deps) {
|
|
|
273
445
|
description: "T1.2.3 — dry-run test a connector by platformId (default dry-run)",
|
|
274
446
|
execute: async (input) => {
|
|
275
447
|
const surface = await Promise.resolve(opsRouter.dispatch("connector_test", input));
|
|
448
|
+
flush();
|
|
276
449
|
return surface;
|
|
277
450
|
},
|
|
278
451
|
},
|
|
@@ -292,6 +465,7 @@ export function createCliCommands(deps) {
|
|
|
292
465
|
description: "T1.2.4 — owner-governed goal operations: set, list, accept, reject",
|
|
293
466
|
execute: async (input) => {
|
|
294
467
|
const surface = await Promise.resolve(opsRouter.dispatch("goal", input));
|
|
468
|
+
flush();
|
|
295
469
|
return surface;
|
|
296
470
|
},
|
|
297
471
|
},
|
package/runtime/cli/index.js
CHANGED
|
@@ -290,6 +290,8 @@ export function createCommandRouter(options = {}) {
|
|
|
290
290
|
readModels: runtime.readModels,
|
|
291
291
|
actionBridge: runtime.actionBridge,
|
|
292
292
|
opsRouter,
|
|
293
|
+
stateDb: runtime.stateDb,
|
|
294
|
+
observabilityDb: runtime.observabilityDb,
|
|
293
295
|
});
|
|
294
296
|
return {
|
|
295
297
|
commands,
|
|
@@ -1067,6 +1067,8 @@ export function createOpsRouter(deps) {
|
|
|
1067
1067
|
hasRealClosure: realRunResult.gate.hasRealClosure,
|
|
1068
1068
|
hasQuietArtifact: realRunResult.gate.hasQuietArtifact,
|
|
1069
1069
|
hasDreamArtifact: realRunResult.gate.hasDreamArtifact,
|
|
1070
|
+
hasFreshImpulseContext: realRunResult.gate.hasFreshImpulseContext,
|
|
1071
|
+
hasProjectionFeedback: realRunResult.gate.hasProjectionFeedback,
|
|
1070
1072
|
missingStage: realRunResult.gate.missingStage,
|
|
1071
1073
|
missingReason: realRunResult.gate.missingReason,
|
|
1072
1074
|
};
|
|
@@ -1079,6 +1081,8 @@ export function createOpsRouter(deps) {
|
|
|
1079
1081
|
hasRealClosure: false,
|
|
1080
1082
|
hasQuietArtifact: false,
|
|
1081
1083
|
hasDreamArtifact: false,
|
|
1084
|
+
hasFreshImpulseContext: false,
|
|
1085
|
+
hasProjectionFeedback: false,
|
|
1082
1086
|
missingReason: "Real-run health check degraded: " + realRunResult.degraded.reason,
|
|
1083
1087
|
};
|
|
1084
1088
|
}
|
|
@@ -1140,24 +1144,30 @@ export function createOpsRouter(deps) {
|
|
|
1140
1144
|
};
|
|
1141
1145
|
return envelope;
|
|
1142
1146
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1147
|
+
let fromVersion = typeof input?.from === "string" ? input.from : "";
|
|
1148
|
+
let toVersion = typeof input?.to === "string" ? input.to : "";
|
|
1145
1149
|
if (!fromVersion || !toVersion) {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1150
|
+
// Auto-resolve the two most recent narrative timeline versions when not provided.
|
|
1151
|
+
const recent = await deps.narrativeTimelineDeps.stateMemoryPort.listNarrativeTimeline(new Date(0).toISOString(), new Date().toISOString(), { limit: 2 });
|
|
1152
|
+
if (recent.length < 2) {
|
|
1153
|
+
const envelope = {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
command: "narrative:diff",
|
|
1156
|
+
runtimeMode: "workspace_full_runtime",
|
|
1157
|
+
surfaceMode: "cli",
|
|
1158
|
+
generatedAt,
|
|
1159
|
+
error: {
|
|
1160
|
+
code: "NARRATIVE_DIFF_REQUIRES_TWO_VERSIONS",
|
|
1161
|
+
message: `narrative:diff requires at least two timeline versions; found ${recent.length}. Pass explicit 'from' and 'to', or run snapshot:capture twice.`,
|
|
1162
|
+
nextStep: "run_snapshot_capture_twice_or_pass_from_and_to",
|
|
1163
|
+
},
|
|
1164
|
+
warnings: [],
|
|
1165
|
+
sourceRefs: [],
|
|
1166
|
+
};
|
|
1167
|
+
return envelope;
|
|
1168
|
+
}
|
|
1169
|
+
fromVersion = recent[1].version;
|
|
1170
|
+
toVersion = recent[0].version;
|
|
1161
1171
|
}
|
|
1162
1172
|
try {
|
|
1163
1173
|
const diff = await queryNarrativeDiff(fromVersion, toVersion, deps.narrativeTimelineDeps);
|
|
@@ -73,6 +73,7 @@ export interface CooldownLedgerPort {
|
|
|
73
73
|
loadCooldownState(platformId: string, intent: CapabilityIntent): Promise<{
|
|
74
74
|
blocked: boolean;
|
|
75
75
|
retryAfterMs?: number;
|
|
76
|
+
reason?: string;
|
|
76
77
|
}>;
|
|
77
78
|
}
|
|
78
79
|
export interface RouteContextPort extends CredentialContextPort, CooldownLedgerPort {
|
|
@@ -61,6 +61,23 @@ function readRetryAfterMs(input) {
|
|
|
61
61
|
}
|
|
62
62
|
return undefined;
|
|
63
63
|
}
|
|
64
|
+
function readStatusCode(record) {
|
|
65
|
+
if (typeof record.status === "number")
|
|
66
|
+
return record.status;
|
|
67
|
+
if (typeof record.statusCode === "number")
|
|
68
|
+
return record.statusCode;
|
|
69
|
+
if (typeof record.status === "string") {
|
|
70
|
+
const parsed = Number.parseInt(record.status, 10);
|
|
71
|
+
if (Number.isFinite(parsed))
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
if (typeof record.statusCode === "string") {
|
|
75
|
+
const parsed = Number.parseInt(record.statusCode, 10);
|
|
76
|
+
if (Number.isFinite(parsed))
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
64
81
|
export function classifyFailure(error) {
|
|
65
82
|
if (error instanceof ConnectorPolicyError) {
|
|
66
83
|
return {
|
|
@@ -74,6 +91,34 @@ export function classifyFailure(error) {
|
|
|
74
91
|
}
|
|
75
92
|
if (error && typeof error === "object") {
|
|
76
93
|
const record = error;
|
|
94
|
+
const status = readStatusCode(record);
|
|
95
|
+
if (status !== undefined) {
|
|
96
|
+
if (status === 429) {
|
|
97
|
+
return {
|
|
98
|
+
class: "rate_limited",
|
|
99
|
+
retryable: RETRYABLE_BY_CLASS.rate_limited,
|
|
100
|
+
retryAfterMs: readRetryAfterMs(record),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (status === 401 || status === 403) {
|
|
104
|
+
return {
|
|
105
|
+
class: "auth_failure",
|
|
106
|
+
retryable: RETRYABLE_BY_CLASS.auth_failure,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (status === 400 || status === 404 || status === 422) {
|
|
110
|
+
return {
|
|
111
|
+
class: "permanent_input_error",
|
|
112
|
+
retryable: RETRYABLE_BY_CLASS.permanent_input_error,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (status >= 500 && status <= 599) {
|
|
116
|
+
return {
|
|
117
|
+
class: "transport_failure",
|
|
118
|
+
retryable: RETRYABLE_BY_CLASS.transport_failure,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
77
122
|
const code = record.code;
|
|
78
123
|
if (typeof code === "string") {
|
|
79
124
|
if (code === "auth_failure")
|
|
@@ -152,32 +197,6 @@ export function classifyFailure(error) {
|
|
|
152
197
|
retryable: RETRYABLE_BY_CLASS.unknown_platform_change,
|
|
153
198
|
};
|
|
154
199
|
}
|
|
155
|
-
const status = record.status;
|
|
156
|
-
if (status === 429) {
|
|
157
|
-
return {
|
|
158
|
-
class: "rate_limited",
|
|
159
|
-
retryable: RETRYABLE_BY_CLASS.rate_limited,
|
|
160
|
-
retryAfterMs: readRetryAfterMs(record),
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
if (status === 401 || status === 403) {
|
|
164
|
-
return {
|
|
165
|
-
class: "auth_failure",
|
|
166
|
-
retryable: RETRYABLE_BY_CLASS.auth_failure,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
if (status === 400 || status === 404 || status === 422) {
|
|
170
|
-
return {
|
|
171
|
-
class: "permanent_input_error",
|
|
172
|
-
retryable: RETRYABLE_BY_CLASS.permanent_input_error,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
176
|
-
return {
|
|
177
|
-
class: "transport_failure",
|
|
178
|
-
retryable: RETRYABLE_BY_CLASS.transport_failure,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
200
|
}
|
|
182
201
|
return {
|
|
183
202
|
class: "unknown_platform_change",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectorCooldownPort — Durable cooldown ledger for repeated terminal failures.
|
|
3
|
+
*
|
|
4
|
+
* Core logic: Track terminal failures per platform/capability and block replay
|
|
5
|
+
* for a bounded window after repeated failures. Successful recovery is allowed
|
|
6
|
+
* to bypass stale cooldown.
|
|
7
|
+
*
|
|
8
|
+
* Design authority:
|
|
9
|
+
* - `.anws/v8/04_SYSTEM_DESIGN/connector-system.md §6`
|
|
10
|
+
* - `.anws/v8/04_SYSTEM_DESIGN/body-tool-system.md §4`
|
|
11
|
+
*
|
|
12
|
+
* Dependencies:
|
|
13
|
+
* - `src/storage/v8-state-stores.js` (read/write connector cooldown state)
|
|
14
|
+
* - `src/connectors/base/failure-taxonomy.js` (FailureClass, retryable lookup)
|
|
15
|
+
*
|
|
16
|
+
* Boundary:
|
|
17
|
+
* - Does not execute connectors; only records/read cooldown state.
|
|
18
|
+
* - Does not permanently blacklist platforms; cooldown expires.
|
|
19
|
+
*/
|
|
20
|
+
import type { StateDatabase } from "../../storage/db/index.js";
|
|
21
|
+
import type { CooldownPort } from "../base/policy-layer.js";
|
|
22
|
+
export declare function createConnectorCooldownPort(db: StateDatabase): CooldownPort;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectorCooldownPort — Durable cooldown ledger for repeated terminal failures.
|
|
3
|
+
*
|
|
4
|
+
* Core logic: Track terminal failures per platform/capability and block replay
|
|
5
|
+
* for a bounded window after repeated failures. Successful recovery is allowed
|
|
6
|
+
* to bypass stale cooldown.
|
|
7
|
+
*
|
|
8
|
+
* Design authority:
|
|
9
|
+
* - `.anws/v8/04_SYSTEM_DESIGN/connector-system.md §6`
|
|
10
|
+
* - `.anws/v8/04_SYSTEM_DESIGN/body-tool-system.md §4`
|
|
11
|
+
*
|
|
12
|
+
* Dependencies:
|
|
13
|
+
* - `src/storage/v8-state-stores.js` (read/write connector cooldown state)
|
|
14
|
+
* - `src/connectors/base/failure-taxonomy.js` (FailureClass, retryable lookup)
|
|
15
|
+
*
|
|
16
|
+
* Boundary:
|
|
17
|
+
* - Does not execute connectors; only records/read cooldown state.
|
|
18
|
+
* - Does not permanently blacklist platforms; cooldown expires.
|
|
19
|
+
*/
|
|
20
|
+
import { readConnectorCooldownState, writeConnectorCooldownState, } from "../../storage/v8-state-stores.js";
|
|
21
|
+
// ───────────────────────────────────────────────────────────────
|
|
22
|
+
// Config
|
|
23
|
+
// ───────────────────────────────────────────────────────────────
|
|
24
|
+
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
25
|
+
const TERMINAL_FAILURE_THRESHOLD = 2;
|
|
26
|
+
const RETRYABLE_FAILURE_CLASSES = new Set([
|
|
27
|
+
"transport_failure",
|
|
28
|
+
"rate_limited",
|
|
29
|
+
"timeout",
|
|
30
|
+
"concurrency_conflict",
|
|
31
|
+
]);
|
|
32
|
+
// ───────────────────────────────────────────────────────────────
|
|
33
|
+
// Helpers
|
|
34
|
+
// ───────────────────────────────────────────────────────────────
|
|
35
|
+
function makeCooldownId(platformId, capabilityId) {
|
|
36
|
+
return `cooldown_${platformId}_${capabilityId}`;
|
|
37
|
+
}
|
|
38
|
+
function addMs(iso, ms) {
|
|
39
|
+
return new Date(new Date(iso).getTime() + ms).toISOString();
|
|
40
|
+
}
|
|
41
|
+
function isAfter(a, b) {
|
|
42
|
+
return new Date(a).getTime() > new Date(b).getTime();
|
|
43
|
+
}
|
|
44
|
+
// ───────────────────────────────────────────────────────────────
|
|
45
|
+
// Public API
|
|
46
|
+
// ───────────────────────────────────────────────────────────────
|
|
47
|
+
export function createConnectorCooldownPort(db) {
|
|
48
|
+
return {
|
|
49
|
+
async isBlocked(platformId, intent) {
|
|
50
|
+
const read = await readConnectorCooldownState(db, platformId, intent);
|
|
51
|
+
if (read.degraded) {
|
|
52
|
+
// Fail-closed: if we cannot read cooldown state, prevent replay to avoid hammering
|
|
53
|
+
return {
|
|
54
|
+
blocked: true,
|
|
55
|
+
reason: "cooldown_state_unreadable",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!read.row) {
|
|
59
|
+
return { blocked: false };
|
|
60
|
+
}
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const blocked = isAfter(read.row.blockedUntil, now);
|
|
63
|
+
return {
|
|
64
|
+
blocked,
|
|
65
|
+
retryAfterMs: blocked
|
|
66
|
+
? Math.max(0, new Date(read.row.blockedUntil).getTime() - new Date(now).getTime())
|
|
67
|
+
: undefined,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
async markFailure(platformId, intent, failureClass, retryAfterMs) {
|
|
71
|
+
const id = makeCooldownId(platformId, intent);
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const existing = await readConnectorCooldownState(db, platformId, intent);
|
|
74
|
+
const isRetryable = RETRYABLE_FAILURE_CLASSES.has(failureClass);
|
|
75
|
+
let failureCount = 1;
|
|
76
|
+
let terminalCount = isRetryable ? 0 : 1;
|
|
77
|
+
let blockedUntil = now;
|
|
78
|
+
if (!existing.degraded && existing.row) {
|
|
79
|
+
failureCount = existing.row.failureCount + 1;
|
|
80
|
+
terminalCount = (existing.row.terminalCount ?? 0) + (isRetryable ? 0 : 1);
|
|
81
|
+
// Extend blocked window if already blocked
|
|
82
|
+
if (isAfter(existing.row.blockedUntil, now)) {
|
|
83
|
+
blockedUntil = existing.row.blockedUntil;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (retryAfterMs && retryAfterMs > 0) {
|
|
87
|
+
// Rate-limit or explicit retry-after takes precedence
|
|
88
|
+
blockedUntil = addMs(now, retryAfterMs);
|
|
89
|
+
}
|
|
90
|
+
else if (!isRetryable && terminalCount >= TERMINAL_FAILURE_THRESHOLD) {
|
|
91
|
+
// Repeated terminal failures enter bounded cooldown
|
|
92
|
+
blockedUntil = addMs(now, DEFAULT_COOLDOWN_MS);
|
|
93
|
+
}
|
|
94
|
+
else if (isRetryable) {
|
|
95
|
+
// Retryable failures do not accumulate terminal cooldown
|
|
96
|
+
blockedUntil = now;
|
|
97
|
+
}
|
|
98
|
+
await writeConnectorCooldownState(db, {
|
|
99
|
+
id,
|
|
100
|
+
platformId,
|
|
101
|
+
capabilityId: intent,
|
|
102
|
+
failureClass,
|
|
103
|
+
retryAfterMs: retryAfterMs ?? null,
|
|
104
|
+
blockedUntil,
|
|
105
|
+
failureCount,
|
|
106
|
+
terminalCount,
|
|
107
|
+
sourceRefs: [
|
|
108
|
+
{
|
|
109
|
+
uri: `sn://cooldown/${platformId}/${intent}`,
|
|
110
|
+
family: "audit",
|
|
111
|
+
id,
|
|
112
|
+
redactionClass: "none",
|
|
113
|
+
resolveStatus: "resolvable",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
payloadJson: JSON.stringify({ markedAt: now, failureCount, terminalCount, isRetryable }),
|
|
117
|
+
createdAt: existing.row?.createdAt ?? now,
|
|
118
|
+
updatedAt: now,
|
|
119
|
+
redactionClass: "none",
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|