@nanobpm/nano-workforce 0.70.2 → 0.72.0
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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +77 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +3 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Unit tests for the contract reconciliation pass (issue #227, ADR 0004).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals } from "#test-assert";
|
|
4
|
+
import type { Contract } from "./contracts.ts";
|
|
5
|
+
import {
|
|
6
|
+
formatReconciliationReport,
|
|
7
|
+
reconcileContracts,
|
|
8
|
+
reconcileRegistry,
|
|
9
|
+
} from "./contractReconcile.ts";
|
|
10
|
+
|
|
11
|
+
test("reconcileRegistry: the committed registry reconciles clean (no synonyms/contradictions)", () => {
|
|
12
|
+
assertEquals(reconcileRegistry(), []);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("reconcileRegistry: two same-category entries with equivalent semantics are flagged as synonyms", () => {
|
|
16
|
+
const contracts: Contract[] = [
|
|
17
|
+
{ category: "env", name: "NANO_A_URL", owner: "a", semantics: "externally reachable base url agents use to reach this app" },
|
|
18
|
+
{ category: "env", name: "NANO_B_URL", owner: "b", semantics: "externally reachable base url agents use to reach this app" },
|
|
19
|
+
];
|
|
20
|
+
const findings = reconcileRegistry(contracts);
|
|
21
|
+
assertEquals(findings.length, 1, "one symmetric synonym pair, reported once");
|
|
22
|
+
assertEquals(findings[0].kind, "synonym");
|
|
23
|
+
assertEquals(findings[0].names.sort(), ["NANO_A_URL", "NANO_B_URL"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("reconcileContracts: an in-flight signal for a contract not in the registry is mock-vs-real skew", () => {
|
|
27
|
+
const report = reconcileContracts([
|
|
28
|
+
{ authorTask: "task-x", category: "env", name: "NANO_GHOST", body: "some new key nobody landed" },
|
|
29
|
+
]);
|
|
30
|
+
assert(
|
|
31
|
+
report.findings.some((f) => f.kind === "mock-vs-real-skew" && f.names.includes("NANO_GHOST")),
|
|
32
|
+
"a signalled contract absent from the durable registry must be flagged as skew",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("reconcileContracts: a signal reintroducing a rejected synonym is flagged", () => {
|
|
37
|
+
const report = reconcileContracts([
|
|
38
|
+
{ authorTask: "task-y", category: "env", name: "NANO_PR_BASE_URL", body: "the base url" },
|
|
39
|
+
]);
|
|
40
|
+
assert(
|
|
41
|
+
report.findings.some((f) => f.kind === "rejected-synonym"),
|
|
42
|
+
"a rejected synonym signalled on the blackboard must be reconciled",
|
|
43
|
+
);
|
|
44
|
+
// A rejected synonym must NOT also be reported as mock-vs-real skew: the right action is to reuse
|
|
45
|
+
// the canonical name, not to "land" the retired name in the registry — a skew finding there is
|
|
46
|
+
// noise that contradicts the rejected-synonym advice.
|
|
47
|
+
assertEquals(
|
|
48
|
+
report.findings.filter((f) => f.kind === "mock-vs-real-skew"),
|
|
49
|
+
[],
|
|
50
|
+
"a rejected synonym must not double-report as skew",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("reconcileContracts: a synonym signal (different name, same thing) is flagged synonym, not skew", () => {
|
|
55
|
+
const report = reconcileContracts([
|
|
56
|
+
{
|
|
57
|
+
authorTask: "task-w",
|
|
58
|
+
category: "env",
|
|
59
|
+
name: "NANO_APP_PUBLIC_URL",
|
|
60
|
+
body: "Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
assert(
|
|
64
|
+
report.findings.some((f) => f.kind === "synonym"),
|
|
65
|
+
"a differently-named duplicate of an existing contract must be flagged as a synonym",
|
|
66
|
+
);
|
|
67
|
+
assertEquals(
|
|
68
|
+
report.findings.filter((f) => f.kind === "mock-vs-real-skew"),
|
|
69
|
+
[],
|
|
70
|
+
"a synonym must not double-report as skew — reuse the canonical, don't land the new name",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("reconcileContracts: a signal for an existing registry contract, reused correctly, is clean of skew", () => {
|
|
75
|
+
const report = reconcileContracts([
|
|
76
|
+
{
|
|
77
|
+
authorTask: "task-z",
|
|
78
|
+
category: "env",
|
|
79
|
+
name: "NANO_WORKFORCE_BASE_URL",
|
|
80
|
+
body: "Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
assertEquals(report.findings.filter((f) => f.kind === "mock-vs-real-skew"), []);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("formatReconciliationReport: clean report reads clean, dirty report lists findings", () => {
|
|
87
|
+
assert(formatReconciliationReport({ findings: [], clean: true }).includes("no synonyms"));
|
|
88
|
+
const dirty = formatReconciliationReport({
|
|
89
|
+
findings: [{ kind: "synonym", detail: "x looks like y", names: ["x", "y"], source: "registry" }],
|
|
90
|
+
clean: false,
|
|
91
|
+
});
|
|
92
|
+
assert(dirty.includes("1 issue"));
|
|
93
|
+
assert(dirty.includes("[synonym]"));
|
|
94
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// nano-workforce — the contract reconciliation pass (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// A sibling to the L2 epic retro / cross-epic pass. Where the retro distils *learnings*, this pass
|
|
4
|
+
// reconciles *contracts*: it reads the whole blackboard (every `contract` signal siblings posted)
|
|
5
|
+
// alongside the durable contract registry (`app/contracts.ts`) and flags —
|
|
6
|
+
//
|
|
7
|
+
// - SYNONYMS: two declarations of one thing under different names (the #223 env-key failure mode),
|
|
8
|
+
// - CONTRADICTIONS: one name declared with two different meanings/owners,
|
|
9
|
+
// - MOCK-vs-REAL SKEW: a contract signalled in-flight on the blackboard that never landed in the
|
|
10
|
+
// durable registry (a live signal with no durable truth — the pair the issue says must both hold).
|
|
11
|
+
//
|
|
12
|
+
// The pass is PURE and advisory: it returns a report. The caller (a reconciliation agent, or the CI
|
|
13
|
+
// check `scripts/check-contracts.ts` for the registry-only static half) decides whether to surface an
|
|
14
|
+
// escalation / merge candidate. Nothing here mutates the blackboard or the registry.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
allContracts,
|
|
18
|
+
type Contract,
|
|
19
|
+
type ContractCategory,
|
|
20
|
+
detectDeclarationConflicts,
|
|
21
|
+
rejectedEnvSynonyms,
|
|
22
|
+
} from "./contracts.ts";
|
|
23
|
+
|
|
24
|
+
/** An in-flight contract signal, as posted to the blackboard `contract` kind. `category`/`name` come
|
|
25
|
+
* from the `<category>:<name>` `dedupe_key` convention; `body` carries the human semantics. */
|
|
26
|
+
export interface ContractSignal {
|
|
27
|
+
readonly authorTask: string;
|
|
28
|
+
readonly category?: ContractCategory;
|
|
29
|
+
readonly name?: string;
|
|
30
|
+
readonly body: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One reconciliation finding. */
|
|
34
|
+
export interface ReconciliationFinding {
|
|
35
|
+
readonly kind: "synonym" | "contradiction" | "rejected-synonym" | "mock-vs-real-skew";
|
|
36
|
+
readonly detail: string;
|
|
37
|
+
/** The contract name(s) the finding concerns. */
|
|
38
|
+
readonly names: string[];
|
|
39
|
+
/** Whoever raised the divergence, when known (a blackboard author, or "registry" for a static one). */
|
|
40
|
+
readonly source: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReconciliationReport {
|
|
44
|
+
readonly findings: ReconciliationFinding[];
|
|
45
|
+
/** Convenience: true when there is nothing to reconcile (findings is empty). */
|
|
46
|
+
readonly clean: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reconcile the durable registry against itself (the static half): synonyms among registry entries
|
|
50
|
+
* of one category, and any rejected synonym that somehow re-entered the registry. Used both by the
|
|
51
|
+
* full pass and by the CI check (which runs with no blackboard). */
|
|
52
|
+
export function reconcileRegistry(contracts: Contract[] = allContracts()): ReconciliationFinding[] {
|
|
53
|
+
const findings: ReconciliationFinding[] = [];
|
|
54
|
+
const rejected = rejectedEnvSynonyms();
|
|
55
|
+
for (const c of contracts) {
|
|
56
|
+
if (c.category === "env" && rejected.has(c.name)) {
|
|
57
|
+
findings.push({
|
|
58
|
+
kind: "rejected-synonym",
|
|
59
|
+
detail: `Registry entry '${c.name}' is a retired synonym of '${rejected.get(c.name)}' — remove it.`,
|
|
60
|
+
names: [c.name, rejected.get(c.name) ?? ""],
|
|
61
|
+
source: "registry",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Pairwise synonym detection within the registry: reuse the declaration detector so the definition
|
|
66
|
+
// of "synonym" lives in ONE place (app/contracts.ts). Compare each entry against the others; dedupe
|
|
67
|
+
// symmetric pairs by only reporting name < otherName.
|
|
68
|
+
for (const c of contracts) {
|
|
69
|
+
const others = contracts.filter((o) => o.name !== c.name);
|
|
70
|
+
for (const conflict of detectDeclarationConflicts({ category: c.category, name: c.name, semantics: c.semantics }, others)) {
|
|
71
|
+
if (conflict.kind !== "synonym") continue;
|
|
72
|
+
if (c.name >= conflict.existingName) continue; // report each pair once
|
|
73
|
+
findings.push({
|
|
74
|
+
kind: "synonym",
|
|
75
|
+
detail: conflict.detail,
|
|
76
|
+
names: [c.name, conflict.existingName],
|
|
77
|
+
source: "registry",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return findings;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The full pass: reconcile the accumulated blackboard `contract` signals against the durable
|
|
85
|
+
* registry, plus the registry against itself. */
|
|
86
|
+
export function reconcileContracts(
|
|
87
|
+
signals: ContractSignal[],
|
|
88
|
+
contracts: Contract[] = allContracts(),
|
|
89
|
+
): ReconciliationReport {
|
|
90
|
+
const findings: ReconciliationFinding[] = [...reconcileRegistry(contracts)];
|
|
91
|
+
const byName = new Map(contracts.map((c) => [c.name, c]));
|
|
92
|
+
|
|
93
|
+
for (const sig of signals) {
|
|
94
|
+
// A structured signal (has category+name) can be checked against the registry precisely.
|
|
95
|
+
if (sig.category && sig.name) {
|
|
96
|
+
const conflicts = detectDeclarationConflicts(
|
|
97
|
+
{ category: sig.category, name: sig.name, semantics: sig.body },
|
|
98
|
+
contracts,
|
|
99
|
+
);
|
|
100
|
+
for (const conflict of conflicts) {
|
|
101
|
+
findings.push({
|
|
102
|
+
kind: conflict.kind === "synonym" ? "synonym" : conflict.kind === "contradiction" ? "contradiction" : "rejected-synonym",
|
|
103
|
+
detail: `${sig.authorTask}: ${conflict.detail}`,
|
|
104
|
+
names: [conflict.proposedName, conflict.existingName].filter(Boolean),
|
|
105
|
+
source: sig.authorTask,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Mock-vs-real skew: an in-flight signal for a contract that never landed in the durable
|
|
109
|
+
// registry. The blackboard is the live signal; the registry is the durable truth. A signal
|
|
110
|
+
// with no registry entry is exactly the divergence-only-at-runtime risk the issue names.
|
|
111
|
+
// BUT a signal already flagged as a synonym or rejected synonym must NOT also be reported as
|
|
112
|
+
// skew: the correct action is to reuse the existing canonical contract, not to "land" the
|
|
113
|
+
// proposed (synonymous/retired) name in the registry — so a skew finding there is noise that
|
|
114
|
+
// contradicts the synonym advice.
|
|
115
|
+
const isSynonymish = conflicts.some((c) => c.kind === "synonym" || c.kind === "rejected-synonym");
|
|
116
|
+
if (!byName.has(sig.name) && !isSynonymish) {
|
|
117
|
+
findings.push({
|
|
118
|
+
kind: "mock-vs-real-skew",
|
|
119
|
+
detail: `${sig.authorTask}: signalled contract '${sig.category}:${sig.name}' on the blackboard but it is not in the durable registry — land it in app/contracts.ts or it stays a mock-only agreement.`,
|
|
120
|
+
names: [sig.name],
|
|
121
|
+
source: sig.authorTask,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { findings, clean: findings.length === 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render a report as a short, human/agent-readable text block (for an escalation body or a log). */
|
|
130
|
+
export function formatReconciliationReport(report: ReconciliationReport): string {
|
|
131
|
+
if (report.clean) return "Contract reconciliation: no synonyms, contradictions, or mock-vs-real skew found.";
|
|
132
|
+
const lines = report.findings.map((f) => `- [${f.kind}] ${f.detail}`);
|
|
133
|
+
return `Contract reconciliation found ${report.findings.length} issue(s):\n${lines.join("\n")}`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Unit tests for the durable contract registry + typed env schema (issue #227, ADR 0004).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals } from "#test-assert";
|
|
4
|
+
import {
|
|
5
|
+
allContracts,
|
|
6
|
+
detectDeclarationConflicts,
|
|
7
|
+
ENV_CONTRACTS,
|
|
8
|
+
envContract,
|
|
9
|
+
readEnv,
|
|
10
|
+
readEnvOr,
|
|
11
|
+
rejectedEnvSynonyms,
|
|
12
|
+
} from "./contracts.ts";
|
|
13
|
+
|
|
14
|
+
test("readEnv: trims, treats blank/whitespace as unset, reads the injected env", () => {
|
|
15
|
+
assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", { NANO_WORKFORCE_BASE_URL: " https://x " }), "https://x");
|
|
16
|
+
assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", { NANO_WORKFORCE_BASE_URL: " " }), undefined);
|
|
17
|
+
assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", {}), undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("readEnvOr: falls back to the registry default, then to the given fallback", () => {
|
|
21
|
+
assertEquals(readEnvOr("NANO_WORKFORCE_BASE_URL", "x", {}), "http://localhost:3000");
|
|
22
|
+
assertEquals(readEnvOr("NANO_WORKFORCE_BASE_URL", "x", { NANO_WORKFORCE_BASE_URL: "https://y" }), "https://y");
|
|
23
|
+
// A key with no registered default falls through to the caller's fallback.
|
|
24
|
+
assertEquals(readEnvOr("NANO_ESCALATION_SLA_TIMEOUT", "PT24H", {}), "PT24H");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejectedEnvSynonyms: maps retired base-URL names to the canonical key (#226/#223)", () => {
|
|
28
|
+
const rejected = rejectedEnvSynonyms();
|
|
29
|
+
assertEquals(rejected.get("NANO_PR_PUBLIC_BASE_URL"), "NANO_WORKFORCE_BASE_URL");
|
|
30
|
+
assertEquals(rejected.get("NANO_PR_BASE_URL"), "NANO_WORKFORCE_BASE_URL");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("envContract: exposes owner + semantics + default for a declared key", () => {
|
|
34
|
+
const c = envContract("NANO_WORKFORCE_BASE_URL");
|
|
35
|
+
assertEquals(c.name, "NANO_WORKFORCE_BASE_URL");
|
|
36
|
+
assertEquals(c.default, "http://localhost:3000");
|
|
37
|
+
assert(c.semantics.length > 0, "an env contract must carry semantics");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("allContracts: includes env, wire, type, and capability-url entries", () => {
|
|
41
|
+
const cats = new Set(allContracts().map((c) => c.category));
|
|
42
|
+
for (const cat of ["env", "wire", "type", "capability-url"] as const) {
|
|
43
|
+
assert(cats.has(cat), `registry must carry a ${cat} contract`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("detectDeclarationConflicts: a differently-named env key with equivalent semantics is a synonym", () => {
|
|
48
|
+
const conflicts = detectDeclarationConflicts({
|
|
49
|
+
category: "env",
|
|
50
|
+
name: "NANO_PR_EXTERNAL_BASE_URL",
|
|
51
|
+
semantics:
|
|
52
|
+
"Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
|
|
53
|
+
});
|
|
54
|
+
assert(
|
|
55
|
+
conflicts.some((c) => c.kind === "synonym" && c.existingName === "NANO_WORKFORCE_BASE_URL"),
|
|
56
|
+
"a semantically-equivalent env key must be flagged as a synonym of the existing one",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detectDeclarationConflicts: the same name with different semantics is a contradiction", () => {
|
|
61
|
+
const conflicts = detectDeclarationConflicts({
|
|
62
|
+
category: "env",
|
|
63
|
+
name: "NANO_PR_POLL_MS",
|
|
64
|
+
semantics: "completely unrelated meaning about widget colours and fonts",
|
|
65
|
+
});
|
|
66
|
+
assert(
|
|
67
|
+
conflicts.some((c) => c.kind === "contradiction"),
|
|
68
|
+
"a redeclaration of an existing name with different semantics must contradict",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("detectDeclarationConflicts: a retired synonym is flagged as rejected-synonym", () => {
|
|
73
|
+
const conflicts = detectDeclarationConflicts({
|
|
74
|
+
category: "env",
|
|
75
|
+
name: "NANO_PR_BASE_URL",
|
|
76
|
+
semantics: "the base url",
|
|
77
|
+
});
|
|
78
|
+
assertEquals(conflicts[0].kind, "rejected-synonym");
|
|
79
|
+
assertEquals(conflicts[0].existingName, "NANO_WORKFORCE_BASE_URL");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("detectDeclarationConflicts: a genuinely new, distinct contract is clean", () => {
|
|
83
|
+
const conflicts = detectDeclarationConflicts({
|
|
84
|
+
category: "env",
|
|
85
|
+
name: "NANO_WIDGET_TIMEOUT",
|
|
86
|
+
semantics: "milliseconds a widget waits before giving up on a render",
|
|
87
|
+
});
|
|
88
|
+
assertEquals(conflicts, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("every declared env key's registry name matches its key (no internal drift)", () => {
|
|
92
|
+
for (const [key, value] of Object.entries(ENV_CONTRACTS)) {
|
|
93
|
+
assertEquals(value.name, key, `ENV_CONTRACTS['${key}'].name must equal its key`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("config-family keys read via envVar() are declared (the check:contracts blind spot, PR #229)", () => {
|
|
98
|
+
// These are read in production through the `envVar("KEY")` helper (app/version.ts, operations/*,
|
|
99
|
+
// main.ts), which bypasses the typed `readEnv` path. They MUST still be declared, or they are an
|
|
100
|
+
// unregistered second source of truth — exactly the drift the registry exists to kill (#227).
|
|
101
|
+
for (const key of [
|
|
102
|
+
"NANO_PR_WEBHOOK_SECRET",
|
|
103
|
+
"NANO_AGENTIC_SECRET",
|
|
104
|
+
"NANO_AGENTIC",
|
|
105
|
+
"NANO_WORKFORCE_GIT_SHA",
|
|
106
|
+
] as const) {
|
|
107
|
+
assert(key in ENV_CONTRACTS, `${key} must be declared in ENV_CONTRACTS`);
|
|
108
|
+
}
|
|
109
|
+
assertEquals(envContract("NANO_PR_WEBHOOK_SECRET").secret, true);
|
|
110
|
+
assertEquals(envContract("NANO_AGENTIC_SECRET").secret, true);
|
|
111
|
+
});
|