dsh-env-manager-plugin 0.1.0 → 0.1.1
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/LICENSE +21 -21
- package/README.md +273 -250
- package/client.js +2581 -2581
- package/cordis.patch.yml +4 -4
- package/docs/00-project/current-state.md +32 -6
- package/docs/00-project/glossary.md +215 -215
- package/docs/00-project/project-brief.md +114 -114
- package/docs/00-project/roadmap.md +89 -89
- package/docs/01-architecture/DSH_Environment_Service_Memory_Skill_Brainstorm.md +493 -493
- package/docs/01-architecture/api-verification.md +416 -416
- package/docs/01-architecture/architecture-overview.md +222 -222
- package/docs/01-architecture/data-model.md +930 -930
- package/docs/01-architecture/decisions/ADR-0001-plugin-as-bundle.md +29 -29
- package/docs/01-architecture/decisions/ADR-0002-file-based-storage.md +25 -25
- package/docs/01-architecture/decisions/ADR-0003-skill-router-over-search.md +25 -25
- package/docs/01-architecture/decisions/ADR-0004-typescript-host-js-client.md +31 -31
- package/docs/01-architecture/decisions/ADR-0005-layered-rule-resolution.md +36 -36
- package/docs/01-architecture/decisions/ADR-0006-credentials-as-references.md +30 -30
- package/docs/01-architecture/decisions/ADR-0007-session-scoped-default-environment.md +28 -28
- package/docs/01-architecture/decisions/ADR-0008-memory-bridge-not-second-store.md +31 -31
- package/docs/01-architecture/decisions/ADR-0009-client-host-http-transport.md +87 -87
- package/docs/01-architecture/decisions/ADR-0010-environment-plane-model.md +87 -87
- package/docs/01-architecture/decisions/ADR-0011-k8s-node-execution-model.md +92 -92
- package/docs/01-architecture/design.md +666 -666
- package/docs/01-architecture/detailed-design.md +961 -961
- package/docs/01-architecture/implementation-plan.md +323 -323
- package/docs/01-architecture/module-boundaries.md +306 -306
- package/docs/01-architecture/security-model.md +227 -227
- package/docs/01-architecture/storage-contract.md +224 -224
- package/docs/01-architecture/tool-contract.md +438 -438
- package/docs/01-architecture/ui-design.md +587 -587
- package/docs/02-harness/agent-behavior.md +36 -36
- package/docs/02-harness/change-snapshot-policy.md +29 -29
- package/docs/02-harness/client-ui-integration.md +110 -110
- package/docs/02-harness/context-policy.md +43 -43
- package/docs/02-harness/harness-standard.md +91 -91
- package/docs/02-harness/task-execution-policy.md +26 -26
- package/docs/02-harness/verification-policy.md +97 -97
- package/docs/03-openspec/archive-rule.md +25 -25
- package/docs/03-openspec/bugfix-rule.md +30 -30
- package/docs/03-openspec/design-rule.md +33 -33
- package/docs/03-openspec/openspec-usage-guide.md +31 -31
- package/docs/03-openspec/proposal-rule.md +70 -70
- package/docs/03-openspec/spec-rule.md +33 -33
- package/docs/03-openspec/tasks-rule.md +31 -31
- package/docs/04-development/coding-style.md +67 -67
- package/docs/04-development/dependency-policy.md +43 -43
- package/docs/04-development/development-guide.md +85 -85
- package/docs/04-development/directory-convention.md +105 -105
- package/docs/04-development/local-run-guide.md +61 -55
- package/docs/04-development/testing-guide.md +102 -102
- package/docs/05-domain/deployment-template.md +76 -76
- package/docs/05-domain/environment-template.md +165 -165
- package/docs/05-domain/operation-binding-template.md +55 -55
- package/docs/05-domain/operation-record-template.md +55 -55
- package/docs/05-domain/prompt-template.md +90 -90
- package/docs/05-domain/rule-template.md +73 -73
- package/docs/05-domain/service-template.md +39 -39
- package/docs/06-operations/deployment-guide.md +54 -54
- package/docs/06-operations/release.md +198 -188
- package/docs/06-operations/security-policy.md +53 -53
- package/docs/06-operations/troubleshooting.md +141 -91
- package/docs/07-templates/adr-template.md +21 -21
- package/docs/07-templates/bugfix-template.md +11 -11
- package/docs/07-templates/case-template.md +15 -15
- package/docs/07-templates/design-template.md +29 -29
- package/docs/07-templates/proposal-template.md +19 -19
- package/docs/07-templates/spec-template.md +15 -15
- package/docs/07-templates/tasks-template.md +12 -12
- package/docs/99-archive/README.md +13 -13
- package/docs/README.md +142 -142
- package/icon.svg +4 -4
- package/index.js +31 -8
- package/lib/skill.js +37 -37
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +101 -99
- package/scripts/repair-profile.mjs +141 -0
|
@@ -1,961 +1,961 @@
|
|
|
1
|
-
# Detailed Design (V1)
|
|
2
|
-
|
|
3
|
-
> Status: normative implementation design for V1. It refines
|
|
4
|
-
> [`design.md`](design.md) into module-level contracts and depends on the verified
|
|
5
|
-
> runtime facts in [`api-verification.md`](api-verification.md).
|
|
6
|
-
> Record shapes are defined in [`data-model.md`](data-model.md); do not restate or
|
|
7
|
-
> extend them here without updating that document.
|
|
8
|
-
|
|
9
|
-
## 1. Scope
|
|
10
|
-
|
|
11
|
-
This document specifies:
|
|
12
|
-
|
|
13
|
-
1. the plugin configuration and its effective defaults;
|
|
14
|
-
2. every Host module's public interface and internal algorithm;
|
|
15
|
-
3. the exact `defineTool` contract for all ten tools;
|
|
16
|
-
4. the plugin-owned HTTP API used by the Client panel;
|
|
17
|
-
5. error mapping, limits, concurrency and test obligations.
|
|
18
|
-
|
|
19
|
-
UI screens and copy are in [`ui-design.md`](ui-design.md); build and packaging are in
|
|
20
|
-
[`design.md`](design.md) §15 and [`implementation-plan.md`](implementation-plan.md).
|
|
21
|
-
|
|
22
|
-
## 2. Conventions
|
|
23
|
-
|
|
24
|
-
- Host: TypeScript, ESM, `strict`.
|
|
25
|
-
- Runtime validation: Zod. Tool parameter validation: DSH's schema DSL (F3).
|
|
26
|
-
- Files: YAML 1.2 via the `yaml` package; no anchors/aliases are emitted.
|
|
27
|
-
- Time: ISO-8601 UTC with milliseconds, produced by `new Date().toISOString()`.
|
|
28
|
-
- Paths: POSIX separators inside resolved values; OS-native paths only at the
|
|
29
|
-
filesystem boundary.
|
|
30
|
-
- All expected failures are `EnvManagerError extends HarnessError` (F5) with a code
|
|
31
|
-
from `data-model.md` §11.
|
|
32
|
-
|
|
33
|
-
## 3. Configuration
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
// src/config.ts
|
|
37
|
-
import z from '@deepseek-ai/schemastery'
|
|
38
|
-
|
|
39
|
-
export const Config = z.object({
|
|
40
|
-
dataDir: z.string().description('Registry directory; relative paths resolve against the profile directory.'),
|
|
41
|
-
maxRecords: z.natural().default(2000),
|
|
42
|
-
registerSkill: z.boolean().default(true),
|
|
43
|
-
writePolicy: z.object({
|
|
44
|
-
allowUiProductionWrites: z.boolean().default(false),
|
|
45
|
-
requireApprovalForDelete: z.boolean().default(true),
|
|
46
|
-
requireApprovalForProductionSave: z.boolean().default(true),
|
|
47
|
-
}).default({}),
|
|
48
|
-
defaults: z.object({
|
|
49
|
-
environmentId: z.string().default(''),
|
|
50
|
-
}).default({}),
|
|
51
|
-
})
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
| Field | Default | Meaning |
|
|
55
|
-
|---|---|---|
|
|
56
|
-
| `dataDir` | profile data dir | Registry root; see §4.1 |
|
|
57
|
-
| `maxRecords` | 2000 | hard cap per record kind; exceeding it fails a write |
|
|
58
|
-
| `registerSkill` | true | register the profile-wide `dsh-env-manager` discovery Skill |
|
|
59
|
-
| `writePolicy.allowUiProductionWrites` | false | allow the HTTP API to write production records |
|
|
60
|
-
| `writePolicy.requireApprovalForDelete` | true | `env_delete` asks the human first |
|
|
61
|
-
| `writePolicy.requireApprovalForProductionSave` | true | `env_save` on a production environment asks first |
|
|
62
|
-
| `defaults.environmentId` | empty | fallback when neither an argument nor the session default exists |
|
|
63
|
-
|
|
64
|
-
Rules:
|
|
65
|
-
|
|
66
|
-
- The config schema is schemastery (F1/F18), not Zod. Do not add a second config path.
|
|
67
|
-
- Config is read-only at runtime; there is no write API. The Client shows effective
|
|
68
|
-
values only.
|
|
69
|
-
- A blank `dataDir` means "use the resolution order in §4.1", not "current directory".
|
|
70
|
-
|
|
71
|
-
## 4. Storage
|
|
72
|
-
|
|
73
|
-
### 4.1 Data directory resolution
|
|
74
|
-
|
|
75
|
-
```text
|
|
76
|
-
1. config.dataDir, if set:
|
|
77
|
-
absolute -> use as-is
|
|
78
|
-
relative -> path.resolve(profileDir, config.dataDir)
|
|
79
|
-
2. profileDir from ctx.get('profileContext')?.dir
|
|
80
|
-
-> <profileDir>/data/dsh-env-manager
|
|
81
|
-
3. process.env.DSH_PROFILE_DIR
|
|
82
|
-
-> <DSH_PROFILE_DIR>/data/dsh-env-manager
|
|
83
|
-
4. process.cwd()/data/dsh-env-manager
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`profileDir` may be unavailable in an embedded host; step 3 and 4 keep the plugin
|
|
87
|
-
usable. The resolved absolute path is memoized for the process lifetime, logged once,
|
|
88
|
-
and reported by `env_get_context` and the UI overview. It is never derived from model
|
|
89
|
-
input.
|
|
90
|
-
|
|
91
|
-
### 4.2 Layout
|
|
92
|
-
|
|
93
|
-
```text
|
|
94
|
-
<dataDir>/
|
|
95
|
-
├── environments/<environmentId>.yaml
|
|
96
|
-
├── services/<serviceId>.yaml
|
|
97
|
-
├── deployments/<deploymentId>.yaml
|
|
98
|
-
├── rules/<ruleProfileId>.yaml
|
|
99
|
-
├── operations/<bindingId>.yaml
|
|
100
|
-
├── sessions/<sessionId>.json
|
|
101
|
-
└── .backups/<kind>/<id>/<timestamp>.yaml (rolling, max 5)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
- The file name is always `<id>.yaml`; the record `id` must equal the file stem.
|
|
105
|
-
- `sessions/` and `.backups/` are never scanned as registry records.
|
|
106
|
-
- Directories are created lazily by the first write; a missing root on read yields an
|
|
107
|
-
empty registry, not an error.
|
|
108
|
-
|
|
109
|
-
### 4.3 Id and path safety
|
|
110
|
-
|
|
111
|
-
```ts
|
|
112
|
-
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
`resolveRecordPath(kind, id)` rejects a non-matching id **before** touching the
|
|
116
|
-
filesystem, then verifies `path.resolve(dir, id + '.yaml')` starts with
|
|
117
|
-
`path.resolve(dir) + path.sep`. Session ids are validated as `[A-Za-z0-9_-]{1,128}`
|
|
118
|
-
and hashed only if they fail that pattern.
|
|
119
|
-
|
|
120
|
-
### 4.4 Atomic write protocol
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
async function writeRecord(kind, id, record) {
|
|
124
|
-
const target = resolveRecordPath(kind, id)
|
|
125
|
-
await mkdir(dirname(target), { recursive: true })
|
|
126
|
-
const tmp = join(dirname(target), `.${id}.${process.pid}.${randomUUID()}.tmp`)
|
|
127
|
-
const text = stringifyYaml(record)
|
|
128
|
-
const handle = await open(tmp, 'w')
|
|
129
|
-
try {
|
|
130
|
-
await handle.writeFile(text, 'utf8')
|
|
131
|
-
await handle.sync()
|
|
132
|
-
} finally {
|
|
133
|
-
await handle.close()
|
|
134
|
-
}
|
|
135
|
-
await rotateBackup(kind, id, target) // copy previous file, prune to 5
|
|
136
|
-
await rename(tmp, target) // atomic replace on POSIX and Windows
|
|
137
|
-
return { file: target, bytes: Buffer.byteLength(text) }
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Guarantees:
|
|
142
|
-
|
|
143
|
-
- A reader never sees a partially written record.
|
|
144
|
-
- A crash before `rename` leaves an ignorable `.tmp` file; startup cleanup removes
|
|
145
|
-
`*.tmp` older than one hour.
|
|
146
|
-
- Backup failure does not block the write; it is reported as a warning.
|
|
147
|
-
- `writeRecord` is serialized per target path by an in-process promise chain (§4.8).
|
|
148
|
-
|
|
149
|
-
### 4.5 Read protocol
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
async function readRecord(kind, id): Promise<ReadResult> {
|
|
153
|
-
const target = resolveRecordPath(kind, id)
|
|
154
|
-
let text
|
|
155
|
-
try { text = await readFile(target, 'utf8') }
|
|
156
|
-
catch (e) { if (e.code === 'ENOENT') return { found: false }; throw e }
|
|
157
|
-
const parsed = parseYamlDocument(text) // throws RECORD_INVALID
|
|
158
|
-
return { found: true, raw: parsed, text }
|
|
159
|
-
}
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Invalid YAML, a non-object document, an id/filename mismatch, or a schema failure
|
|
163
|
-
produces a `Diagnostic` and the record is treated as absent for that request. The file
|
|
164
|
-
is never rewritten or deleted.
|
|
165
|
-
|
|
166
|
-
Unknown keys are preserved on read and reported as an `UNKNOWN_FIELD` warning, but
|
|
167
|
-
`env_save` rejects them (`RECORD_INVALID`) so a typo cannot enter the store. Storage
|
|
168
|
-
therefore parses twice: `.passthrough()` on read, strict on save.
|
|
169
|
-
|
|
170
|
-
### 4.6 Delete
|
|
171
|
-
|
|
172
|
-
```text
|
|
173
|
-
1. Resolve the target and confirm it exists.
|
|
174
|
-
2. Ask the registry whether any record references it (§6.3).
|
|
175
|
-
3. If referenced -> RECORD_IN_USE listing referrers.
|
|
176
|
-
4. Otherwise rotate a backup, then unlink the file.
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
Deleting a session file is a plain unlink and needs no reference check.
|
|
180
|
-
|
|
181
|
-
### 4.7 Session files
|
|
182
|
-
|
|
183
|
-
```json
|
|
184
|
-
{
|
|
185
|
-
"schemaVersion": 1,
|
|
186
|
-
"sessionId": "01J...",
|
|
187
|
-
"defaultEnvironmentId": "test-a",
|
|
188
|
-
"updatedAt": "2026-11-01T03:00:00.000Z"
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Written with the same temp+rename protocol. A corrupt or unreadable file is reported
|
|
193
|
-
as `persisted: false` and treated as "no default"; it is not deleted.
|
|
194
|
-
|
|
195
|
-
### 4.8 Concurrency
|
|
196
|
-
|
|
197
|
-
- One serialized chain per file path (`Map<string, Promise<void>>`), released when the
|
|
198
|
-
chain drains.
|
|
199
|
-
- Registry loads are deduplicated: concurrent callers share one in-flight load.
|
|
200
|
-
- No cross-process lock in V1. Two DSH processes writing the same file can lose one
|
|
201
|
-
update; the backup makes the loss recoverable. Documented as a limitation.
|
|
202
|
-
|
|
203
|
-
### 4.9 Limits
|
|
204
|
-
|
|
205
|
-
| Limit | Value | Failure |
|
|
206
|
-
|---|---|---|
|
|
207
|
-
| records per kind | `maxRecords` (2000) | `RECORD_LIMIT_EXCEEDED` |
|
|
208
|
-
| record YAML bytes | 1 MiB | `RECORD_TOO_LARGE` |
|
|
209
|
-
| session file bytes | 64 KiB | `SESSION_CONTEXT_UNPERSISTED` |
|
|
210
|
-
|
|
211
|
-
## 5. Domain
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
// src/domain/types.ts
|
|
215
|
-
export type RecordKind = 'environment' | 'service' | 'deployment' | 'ruleProfile' | 'operationBinding'
|
|
216
|
-
export type AnyRecord = Environment | Service | Deployment | RuleProfile | OperationBinding
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
`src/domain/schemas.ts` exports one Zod schema per record plus:
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
export function parseRecord(kind: RecordKind, value: unknown, origin: string):
|
|
223
|
-
{ ok: true; record: AnyRecord } | { ok: false; diagnostic: Diagnostic }
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
Validation responsibilities:
|
|
227
|
-
|
|
228
|
-
- `RecordMeta`: id pattern, `schemaVersion === 1`, ISO timestamps, `updatedAt >= createdAt`.
|
|
229
|
-
- Cross-field rules from `data-model.md`: at least one `deployModes`, host role ids
|
|
230
|
-
exist, `location.kind === runtimeMode`, unique host id / ip / cluster id /
|
|
231
|
-
endpoint id, unique account id per host, `loginRecords[].hostRef`/`accountId`
|
|
232
|
-
resolve within the environment, host `clusterRef` resolves and is present for
|
|
233
|
-
`k8s-all-in-one`/`k8s-worker`, `access` values are unique, `paths` keys and variable
|
|
234
|
-
names match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
235
|
-
- `structural` shape only. Referential integrity is the registry's job (§6.3), because
|
|
236
|
-
it needs the whole snapshot.
|
|
237
|
-
|
|
238
|
-
`parseRecord` never throws for bad data; it returns a diagnostic with `path` pointing
|
|
239
|
-
at the failing field.
|
|
240
|
-
|
|
241
|
-
Tool input is parsed by `parseRecordInput`, which is identical except `createdAt` and
|
|
242
|
-
`updatedAt` are optional. The app then calls `finalizeRecord(record, previous?)` to
|
|
243
|
-
stamp them before `parseRecord` and before writing.
|
|
244
|
-
|
|
245
|
-
## 6. Registry
|
|
246
|
-
|
|
247
|
-
### 6.1 Snapshot
|
|
248
|
-
|
|
249
|
-
```ts
|
|
250
|
-
interface RegistrySnapshot {
|
|
251
|
-
environments: Map<string, Environment>
|
|
252
|
-
services: Map<string, Service>
|
|
253
|
-
deployments: Map<string, Deployment>
|
|
254
|
-
ruleProfiles: Map<string, RuleProfile>
|
|
255
|
-
operationBindings: Map<string, OperationBinding>
|
|
256
|
-
byAlias: Map<string, string> // "service:<alias>" -> id
|
|
257
|
-
diagnostics: Diagnostic[]
|
|
258
|
-
loadedAt: string
|
|
259
|
-
}
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
`byAlias` keys are `${kind}:${alias}` and resolve aliases for `env_get`/`env_find`.
|
|
263
|
-
Duplicate aliases are a warning; the first record wins, sorted by file name for
|
|
264
|
-
determinism.
|
|
265
|
-
|
|
266
|
-
### 6.2 Load algorithm
|
|
267
|
-
|
|
268
|
-
```text
|
|
269
|
-
for each kind directory (environments, services, deployments, rules, operations):
|
|
270
|
-
entries = readdir(dir, { withFileTypes: true })
|
|
271
|
-
files = entries where isFile() and name endsWith('.yaml') and not name startsWith('.')
|
|
272
|
-
sort files by name
|
|
273
|
-
for each file:
|
|
274
|
-
read + parse
|
|
275
|
-
parseRecord -> on failure: diagnostic (file, code, path, message); skip
|
|
276
|
-
if map already has record.id: diagnostic DUPLICATE_ID; skip later file
|
|
277
|
-
else insert
|
|
278
|
-
refresh byAlias and run integrity checks
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
A missing kind directory is normal (empty). An unreadable directory is an error
|
|
282
|
-
diagnostic, and the snapshot keeps the other kinds.
|
|
283
|
-
|
|
284
|
-
### 6.3 Referential integrity
|
|
285
|
-
|
|
286
|
-
| Reference | Rule | Code |
|
|
287
|
-
|---|---|---|
|
|
288
|
-
| `deployment.environmentId` | environment exists | `ENV_NOT_FOUND` |
|
|
289
|
-
| `deployment.serviceId` | service exists | `SERVICE_NOT_FOUND` |
|
|
290
|
-
| `deployment.location.hostRef` | host exists in that environment | `HOST_NOT_FOUND` |
|
|
291
|
-
| `deployment.location.clusterRef` | cluster exists in that environment | `CLUSTER_NOT_FOUND` |
|
|
292
|
-
| environment `hosts[].clusterRef` | cluster exists; required for K8s node kinds | `RECORD_INVALID` |
|
|
293
|
-
| environment `endpoints[].hostRef` / `clusterRef` | target exists in that environment | `RECORD_INVALID` |
|
|
294
|
-
| `service.ruleProfileRefs[]` | rule profile exists | `RULE_PROFILE_NOT_FOUND` |
|
|
295
|
-
| operation binding `match.environmentId` | environment exists | `ENV_NOT_FOUND` |
|
|
296
|
-
| operation binding `match.serviceId` | service exists | `SERVICE_NOT_FOUND` |
|
|
297
|
-
| operation binding `match.deploymentId` | deployment exists | `DEPLOYMENT_NOT_FOUND` |
|
|
298
|
-
| any `credentialRef` | non-empty string matching the agreed ref pattern | `RECORD_INVALID` |
|
|
299
|
-
|
|
300
|
-
Schema validation rejects an environment with zero logical roles and a host with zero
|
|
301
|
-
roles before integrity checks run; both fail as `RECORD_INVALID`. A host role id that is
|
|
302
|
-
not declared in `Environment.roles` is reported as `ROLE_NOT_FOUND` by `env_validate`;
|
|
303
|
-
the panel prevents selecting an undeclared role in the node form.
|
|
304
|
-
|
|
305
|
-
Integrity failures are **diagnostics**, not load failures: the registry still serves
|
|
306
|
-
the other records, and `env_validate` reports the broken references. A resolve or
|
|
307
|
-
route that needs a missing record fails with the specific code.
|
|
308
|
-
|
|
309
|
-
### 6.4 Reload policy
|
|
310
|
-
|
|
311
|
-
V1 reloads the registry at the start of every application request. Rationale: users
|
|
312
|
-
edit YAML by hand, and a stale cache is worse than a few milliseconds. Concurrent
|
|
313
|
-
requests share one in-flight load. A snapshot is replaced only after a successful
|
|
314
|
-
load; a load that throws keeps the previous snapshot and reports an error diagnostic.
|
|
315
|
-
|
|
316
|
-
Performance target: 500 records load in under 100 ms on a local SSD. If that is
|
|
317
|
-
missed, add an mtime-keyed cache in a follow-up, not in V1.
|
|
318
|
-
|
|
319
|
-
## 7. Resolver
|
|
320
|
-
|
|
321
|
-
### 7.1 Template parsing
|
|
322
|
-
|
|
323
|
-
```ts
|
|
324
|
-
type TemplatePart =
|
|
325
|
-
| { kind: 'literal'; text: string }
|
|
326
|
-
| { kind: 'ref'; path: string }
|
|
327
|
-
|
|
328
|
-
function parseTemplate(text: string): TemplatePart[]
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
Scanner rules:
|
|
332
|
-
|
|
333
|
-
1. Find `${`. Text before it is a literal.
|
|
334
|
-
2. Find the next `}`. A missing `}` is `RULE_TEMPLATE_INVALID`.
|
|
335
|
-
3. The inner text must match `^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`;
|
|
336
|
-
otherwise `RULE_TEMPLATE_INVALID`.
|
|
337
|
-
4. Anything that is not `${…}` is literal text, including a lone `$`.
|
|
338
|
-
5. No nesting, escaping, calls, operators, or env access.
|
|
339
|
-
6. Template length is capped at 2048 characters.
|
|
340
|
-
|
|
341
|
-
### 7.2 Namespaces
|
|
342
|
-
|
|
343
|
-
| Reference | Resolves to |
|
|
344
|
-
|---|---|
|
|
345
|
-
| `env.id`, `env.name`, `env.category` | environment record |
|
|
346
|
-
| `service.id`, `service.name`, `service.category` | service record |
|
|
347
|
-
| `service.artifact.name` | service artifact name, when declared |
|
|
348
|
-
| `deployment.id`, `deployment.name`, `deployment.runtimeMode` | deployment record |
|
|
349
|
-
| `deployment.location.<field>` | deployment location, runtime-specific |
|
|
350
|
-
| `<variable>` | layer variable of that name |
|
|
351
|
-
| `variables.<variable>` | same, explicit form |
|
|
352
|
-
| `paths.<pathName>` | another resolved path |
|
|
353
|
-
|
|
354
|
-
`service.artifact.name` resolves only when `artifact` exists. A reference to a
|
|
355
|
-
namespace that is unavailable for this context is `RULE_UNDEFINED_VARIABLE`, not an
|
|
356
|
-
empty string.
|
|
357
|
-
|
|
358
|
-
### 7.3 Layers
|
|
359
|
-
|
|
360
|
-
```ts
|
|
361
|
-
interface Layer {
|
|
362
|
-
source: RuleSource
|
|
363
|
-
variables?: Record<string, string | number | boolean>
|
|
364
|
-
paths?: Record<string, string>
|
|
365
|
-
logSources?: Partial<Record<RuntimeMode, LogSource>>
|
|
366
|
-
classLocations?: Partial<Record<RuntimeMode, ClassLocation>>
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
Built from `design.md` §6.3:
|
|
371
|
-
|
|
372
|
-
1. built-in global defaults (empty in V1);
|
|
373
|
-
2. each `service.ruleProfileRefs` entry, in order;
|
|
374
|
-
3. `environment.ruleOverrides`;
|
|
375
|
-
4. `deployment.variables` (variables only), then `deployment.ruleOverrides`.
|
|
376
|
-
|
|
377
|
-
Flatten to a field map:
|
|
378
|
-
|
|
379
|
-
```text
|
|
380
|
-
variables.<name>
|
|
381
|
-
paths.<name>
|
|
382
|
-
logSources.<runtime>.<property>
|
|
383
|
-
classLocations.<runtime>.<property>
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
### 7.4 Merge
|
|
387
|
-
|
|
388
|
-
```ts
|
|
389
|
-
interface MergedField {
|
|
390
|
-
key: string
|
|
391
|
-
raw: string | number | boolean
|
|
392
|
-
definedBy: RuleSource
|
|
393
|
-
shadowed: RuleSource[]
|
|
394
|
-
}
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Iterate layers in ascending order. For each field, replace the winner and append the
|
|
398
|
-
previous winner to `shadowed`. This is the only merge rule; there is no deep merge and
|
|
399
|
-
no array concatenation.
|
|
400
|
-
|
|
401
|
-
### 7.5 Evaluation
|
|
402
|
-
|
|
403
|
-
```text
|
|
404
|
-
1. Build the reference graph: node = merged field key; edge = each ref used by a
|
|
405
|
-
string field. Constants (env/service/deployment) are not nodes.
|
|
406
|
-
2. DFS with an active stack. A back edge is RULE_CYCLE with the full cycle path.
|
|
407
|
-
3. A ref with no node and no constant is RULE_UNDEFINED_VARIABLE
|
|
408
|
-
(message names the field and the ref).
|
|
409
|
-
4. Resolve nodes in post-order; substitute into an ordered list of parts.
|
|
410
|
-
5. For every resolved `paths.*` value, split on `/` and reject `..`
|
|
411
|
-
(RULE_PATH_TRAVERSAL). Absolute/relative shape is otherwise preserved.
|
|
412
|
-
6. Convert non-string numbers/booleans to their literal text when interpolated.
|
|
413
|
-
7. Select `logSources[runtimeMode]` and `classLocations[runtimeMode]` after their
|
|
414
|
-
strings resolve.
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
Cycle detection is iterative (explicit stack), not recursive, so a pathological graph
|
|
418
|
-
cannot overflow the JS stack.
|
|
419
|
-
|
|
420
|
-
### 7.6 Provenance
|
|
421
|
-
|
|
422
|
-
For every requested field:
|
|
423
|
-
|
|
424
|
-
```ts
|
|
425
|
-
interface ProvenanceEntry {
|
|
426
|
-
field: string
|
|
427
|
-
value: string | number | boolean
|
|
428
|
-
definedBy: RuleSource
|
|
429
|
-
inputs: Array<{ field: string; source: RuleSource }>
|
|
430
|
-
shadowed: RuleSource[]
|
|
431
|
-
}
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
`inputs` records the source of the winning definition of each referenced field, so a
|
|
435
|
-
value that depends on an environment override names the environment layer. `shadowed`
|
|
436
|
-
comes from the merge.
|
|
437
|
-
|
|
438
|
-
`paths` and `variables` produce one entry per name. `logSource` and `classLocation` are
|
|
439
|
-
structured: each resolved property produces its own entry
|
|
440
|
-
(`logSources.kubernetes.namespace`), returned as a list so a mixed-layer object is still
|
|
441
|
-
explainable.
|
|
442
|
-
|
|
443
|
-
### 7.7 Public API
|
|
444
|
-
|
|
445
|
-
```ts
|
|
446
|
-
interface ResolveInput {
|
|
447
|
-
environment: Environment
|
|
448
|
-
service: Service
|
|
449
|
-
deployment?: Deployment
|
|
450
|
-
ruleProfiles: Map<string, RuleProfile>
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function resolveContext(input: ResolveInput): ResolveResult
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
`ResolveResult` is `{ ok: true; context: ServiceContext } | { ok: false; diagnostics: Diagnostic[] }`.
|
|
457
|
-
The function is pure; it never reads files, time, or config.
|
|
458
|
-
|
|
459
|
-
## 8. Router
|
|
460
|
-
|
|
461
|
-
### 8.1 Eligibility
|
|
462
|
-
|
|
463
|
-
A binding is eligible when:
|
|
464
|
-
|
|
465
|
-
- `enabled === true`;
|
|
466
|
-
- `operation` equals the requested id exactly;
|
|
467
|
-
- every declared `match` field matches the context:
|
|
468
|
-
|
|
469
|
-
| Field | Matches |
|
|
470
|
-
|---|---|
|
|
471
|
-
| `environmentId` | context environment id |
|
|
472
|
-
| `serviceId` | context service id |
|
|
473
|
-
| `deploymentId` | context deployment id (requires a deployment) |
|
|
474
|
-
| `deploymentMode` | context runtime mode |
|
|
475
|
-
| `serviceCategory` | service category |
|
|
476
|
-
|
|
477
|
-
An empty `match` matches everything.
|
|
478
|
-
|
|
479
|
-
A binding whose `match` is not one of the five documented shapes is `RECORD_INVALID` at
|
|
480
|
-
load (data-model §7); the router never sees such a binding.
|
|
481
|
-
|
|
482
|
-
### 8.2 Level and selection
|
|
483
|
-
|
|
484
|
-
Rank: `deployment` 1, `environment-service` 2, `service` 3, `category-mode` 4,
|
|
485
|
-
`global` 5. Level is derived from which match fields are present, with
|
|
486
|
-
`deploymentId` winning over `environmentId`+`serviceId`, which wins over `serviceId`,
|
|
487
|
-
then `serviceCategory`+`deploymentMode`, then empty.
|
|
488
|
-
|
|
489
|
-
```text
|
|
490
|
-
eligible = filter(bindings, eligible)
|
|
491
|
-
if eligible is empty: BINDING_NOT_FOUND
|
|
492
|
-
top = min(rank)
|
|
493
|
-
winners = eligible where rank == top
|
|
494
|
-
if winners.length > 1: BINDING_CONFLICT (list every candidate)
|
|
495
|
-
else selected = winners[0]
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
Lower levels are ignored once a winner exists, even if they also match.
|
|
499
|
-
|
|
500
|
-
### 8.3 Skill availability
|
|
501
|
-
|
|
502
|
-
```ts
|
|
503
|
-
interface SkillDirectory {
|
|
504
|
-
snapshot(opts): Promise<{ skills: SkillSummary[]; complete: boolean }>
|
|
505
|
-
}
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
Adapter over `ctx.skills` (F20):
|
|
509
|
-
|
|
510
|
-
| Condition | Result |
|
|
511
|
-
|---|---|
|
|
512
|
-
| `ctx.skills` absent | `available: 'unknown'` + `SKILL_REGISTRY_UNAVAILABLE` warning, `ok: true` |
|
|
513
|
-
| `complete === false` | same |
|
|
514
|
-
| name not found | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
515
|
-
| `expectedSource` set and `source` differs | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
516
|
-
| `expectedProvider` set and `provider` differs | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
517
|
-
| otherwise | `available: true` |
|
|
518
|
-
|
|
519
|
-
Digest pinning is not supported: DSH's catalog exposes no digest. The record field is
|
|
520
|
-
removed in this design revision; do not reintroduce it without a provider that can
|
|
521
|
-
verify it.
|
|
522
|
-
|
|
523
|
-
### 8.4 Policy
|
|
524
|
-
|
|
525
|
-
```text
|
|
526
|
-
write = operation in WRITE_OPERATIONS
|
|
527
|
-
requiresApproval = policy.requiresApproval ?? write
|
|
528
|
-
if context.environmentCategory === 'production' and policy.allowProduction !== true:
|
|
529
|
-
POLICY_DENIED
|
|
530
|
-
else: return policy
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
`requiresApproval` and `allowProduction` are returned to the caller; V1 does not
|
|
534
|
-
execute the Skill. A future executor must enforce them, not just display them.
|
|
535
|
-
|
|
536
|
-
### 8.5 Parameters
|
|
537
|
-
|
|
538
|
-
- Strings are templates resolved through the same engine, with the context namespaces
|
|
539
|
-
and `paths.*`.
|
|
540
|
-
- Numbers and booleans are stringified.
|
|
541
|
-
- A null, array, or object value is `PARAMETER_RESOLUTION_FAILED`.
|
|
542
|
-
- At most 50 parameters; keys must match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
543
|
-
|
|
544
|
-
### 8.6 Public API
|
|
545
|
-
|
|
546
|
-
```ts
|
|
547
|
-
function resolveOperation(input: {
|
|
548
|
-
operation: string
|
|
549
|
-
context: ServiceContext
|
|
550
|
-
bindings: OperationBinding[]
|
|
551
|
-
skills: SkillDirectory | null
|
|
552
|
-
}): OperationResolution
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
Pure except for `skills.snapshot()`, which is an injected async capability. Tests use
|
|
556
|
-
a fake directory.
|
|
557
|
-
|
|
558
|
-
## 9. Session store
|
|
559
|
-
|
|
560
|
-
```ts
|
|
561
|
-
interface SessionContext {
|
|
562
|
-
sessionId: string
|
|
563
|
-
defaultEnvironmentId: string | null
|
|
564
|
-
persisted: boolean
|
|
565
|
-
}
|
|
566
|
-
```
|
|
567
|
-
|
|
568
|
-
- `get(sessionId)`: read `sessions/<id>.json`; missing → `null` default; corrupt →
|
|
569
|
-
`persisted: false`.
|
|
570
|
-
- `set(sessionId, environmentId)`: validate the environment exists, then write the
|
|
571
|
-
file; a write failure returns `persisted: false` and `SESSION_CONTEXT_UNPERSISTED`.
|
|
572
|
-
- `set(..., null)` writes `defaultEnvironmentId: null` (an explicit clear).
|
|
573
|
-
- Two sessions never share a file.
|
|
574
|
-
- A process-local `Map<sessionId, string | null>` caches the last value. `get` prefers
|
|
575
|
-
the file and falls back to the cache when the file is missing or corrupt; a failed
|
|
576
|
-
write still updates the cache so the current process honors the choice. The cache is
|
|
577
|
-
not durable and is empty after a restart.
|
|
578
|
-
|
|
579
|
-
## 10. Application service
|
|
580
|
-
|
|
581
|
-
`src/app/app.ts` is the single place where storage, registry, resolver, router and
|
|
582
|
-
session are composed. Tools and HTTP both call it; neither contains business logic.
|
|
583
|
-
|
|
584
|
-
```ts
|
|
585
|
-
interface App {
|
|
586
|
-
meta(): Promise<MetaResult>
|
|
587
|
-
list(kind: RecordKind): Promise<ListResult>
|
|
588
|
-
get(kind: RecordKind, id: string): Promise<GetResult>
|
|
589
|
-
find(query: string): Promise<FindResult>
|
|
590
|
-
save(kind: RecordKind, record: unknown, options?: SaveOptions): Promise<SaveResult>
|
|
591
|
-
saveYaml(kind: RecordKind, yaml: string, options?: SaveOptions): Promise<SaveResult>
|
|
592
|
-
remove(kind: RecordKind, id: string, actor: Actor, options?: RemoveOptions): Promise<RemoveResult>
|
|
593
|
-
validate(): Promise<ValidationReport>
|
|
594
|
-
resolve(input: ResolveRequest): Promise<ResolveResult>
|
|
595
|
-
route(input: RouteRequest): Promise<OperationResolution>
|
|
596
|
-
setCredential(ref: string, value: string): Promise<{ ref: string; configured: true }>
|
|
597
|
-
unsetCredential(ref: string): Promise<{ ref: string; removed: true }>
|
|
598
|
-
getContext(sessionId: string): Promise<SessionContext>
|
|
599
|
-
setContext(sessionId: string, environmentId: string | null): Promise<SessionContext>
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
interface SaveOptions { dryRun?: boolean }
|
|
603
|
-
interface RemoveOptions { dryRun?: boolean }
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
Rules:
|
|
607
|
-
|
|
608
|
-
- `save` validates with Zod, sets `createdAt` (create only) and `updatedAt`, checks the
|
|
609
|
-
record cap, writes, then reloads the registry. Timestamps supplied by the caller are
|
|
610
|
-
ignored; the stored record always carries both.
|
|
611
|
-
- A schema failure throws `RECORD_INVALID` with
|
|
612
|
-
`details.issues: [{ path, message }]`, where `path` is a record-relative field path
|
|
613
|
-
(`hosts[1].ip`). The UI renders these as inline diagnostics.
|
|
614
|
-
- `save` returns `created: boolean` by comparing existence before the write.
|
|
615
|
-
- `save` re-runs integrity checks after the write and returns warnings for broken
|
|
616
|
-
references; it does not roll back, because the record is individually valid.
|
|
617
|
-
- `remove` refuses when referenced (`RECORD_IN_USE`) and supports a dry-run flag used
|
|
618
|
-
by the UI's confirmation dialog.
|
|
619
|
-
- `resolve` and `route` take explicit ids; the **tool layer** supplies the session
|
|
620
|
-
default when the model omits an id.
|
|
621
|
-
- `Actor` is `'agent' | 'ui'`; the UI actor cannot write production unless
|
|
622
|
-
`writePolicy.allowUiProductionWrites` is true.
|
|
623
|
-
- `resolve` fills `context.credentials` through an injected
|
|
624
|
-
`CredentialDirectory { describe(ref); set(ref, value); unset(ref) }`. The Host
|
|
625
|
-
adapter calls `ctx.credentials.describe`; tests use a fake. A missing ref yields
|
|
626
|
-
`{ ref, configured: false }` plus `CREDENTIAL_MISSING`; no value is ever requested.
|
|
627
|
-
- `setCredential` accepts only `env-manager/<environmentId>/<accountId>` refs and a
|
|
628
|
-
non-empty value, forwards them to `ctx.credentials.set`, and returns only
|
|
629
|
-
`{ ref, configured: true }`. It never writes the value to a record or log.
|
|
630
|
-
`unsetCredential` uses the same namespace check and calls `ctx.credentials.unset`.
|
|
631
|
-
A missing service yields `CREDENTIAL_UNAVAILABLE`; provider failures yield
|
|
632
|
-
`CREDENTIAL_WRITE_FAILED` without exposing the provider message to the UI.
|
|
633
|
-
|
|
634
|
-
## 11. Tools
|
|
635
|
-
|
|
636
|
-
### 11.1 Shared shape
|
|
637
|
-
|
|
638
|
-
```ts
|
|
639
|
-
function toolOutput<T extends JsonSchemaNode>(schema: T, render: Renderer): ToolOutputDefinition
|
|
640
|
-
```
|
|
641
|
-
|
|
642
|
-
- Every tool returns a plain object matching its declared schema; DSH validates it.
|
|
643
|
-
- Every failure throws `EnvManagerError`; no tool returns `{ ok: false }`.
|
|
644
|
-
- `render` emits one text block: a one-line summary plus the essential ids. The
|
|
645
|
-
canonical JSON value is what the model receives.
|
|
646
|
-
|
|
647
|
-
`EnvManagerError`:
|
|
648
|
-
|
|
649
|
-
```ts
|
|
650
|
-
class EnvManagerError extends HarnessError {
|
|
651
|
-
constructor(code: ErrorCode, message: string, details?: JsonValue) {
|
|
652
|
-
super(message, code)
|
|
653
|
-
this.details = details
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
```
|
|
657
|
-
|
|
658
|
-
### 11.2 Tool table
|
|
659
|
-
|
|
660
|
-
| Tool | Parameters | Returns | Session | Approval |
|
|
661
|
-
|---|---|---|---|---|
|
|
662
|
-
| `env_list` | `kind?`, `query?` | `{ items, warnings }` | no | no |
|
|
663
|
-
| `env_get` | `kind`, `id` | `{ record, yaml }` | no | no |
|
|
664
|
-
| `env_find` | `query` | `{ hits }` | no | no |
|
|
665
|
-
| `env_resolve` | `environmentId?`, `serviceId`, `deploymentId?` | `{ context, warnings }` | default env | no |
|
|
666
|
-
| `env_resolve_operation` | `operation`, `environmentId?`, `serviceId?`, `deploymentId?` | `{ resolution }` | yes | no |
|
|
667
|
-
| `env_get_context` | — | `{ sessionId, defaultEnvironmentId, persisted }` | yes | no |
|
|
668
|
-
| `env_set_context` | `environmentId \| null` | same | yes | no |
|
|
669
|
-
| `env_save` | `kind`, `record`, `dryRun?` | `{ id, kind, created, dryRun, warnings }` | no | production env |
|
|
670
|
-
| `env_delete` | `kind`, `id`, `confirm` | `{ id, kind, deleted, blockers }` | no | yes |
|
|
671
|
-
| `env_validate` | — | `{ ok, counts, diagnostics }` | no | no |
|
|
672
|
-
|
|
673
|
-
### 11.3 Parameter definitions
|
|
674
|
-
|
|
675
|
-
```ts
|
|
676
|
-
const kindSchema = {
|
|
677
|
-
type: 'string',
|
|
678
|
-
enum: ['environment', 'service', 'deployment', 'ruleProfile', 'operationBinding'],
|
|
679
|
-
description: 'Record kind.',
|
|
680
|
-
} as const
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
`env_list`:
|
|
684
|
-
|
|
685
|
-
```ts
|
|
686
|
-
parameters: {
|
|
687
|
-
kind: { ...kindSchema, required: false },
|
|
688
|
-
query: { type: 'string', required: false, description: 'Case-insensitive match over id, name and alias.' },
|
|
689
|
-
}
|
|
690
|
-
```
|
|
691
|
-
|
|
692
|
-
`env_get`: `kind` and `id` required, `id` description names the id pattern.
|
|
693
|
-
|
|
694
|
-
`env_find`: `query` required, min length 2.
|
|
695
|
-
|
|
696
|
-
`env_resolve`: `serviceId` required; `environmentId` and `deploymentId` optional.
|
|
697
|
-
Resolving an environment/service pair with more than one deployment and no
|
|
698
|
-
`deploymentId` returns `DEPLOYMENT_AMBIGUOUS` listing candidates; zero deployments
|
|
699
|
-
returns `DEPLOYMENT_NOT_FOUND`.
|
|
700
|
-
|
|
701
|
-
`env_resolve_operation`: `operation` required, `enum` of the 13 standard ids plus a
|
|
702
|
-
free-form custom id — model-visible enum lists only the standard ids; a custom id is
|
|
703
|
-
rejected by the schema unless it is declared in a binding. Because the DSL cannot
|
|
704
|
-
express "either enum or binding-declared", the parameter is a plain string with the
|
|
705
|
-
standard ids in the description and is validated in `execute`
|
|
706
|
-
(`OPERATION_UNKNOWN` when neither standard nor bound).
|
|
707
|
-
|
|
708
|
-
`env_set_context`: `environmentId` is `oneOf` string / `null`.
|
|
709
|
-
|
|
710
|
-
`env_save`: `record` is `{ type: 'json', required: true, description: 'Complete record object.' }`.
|
|
711
|
-
Zod still validates it; the JSON parameter only avoids duplicating every record schema
|
|
712
|
-
in the model-facing tool. `dryRun: true` validates and returns the same shape without
|
|
713
|
-
writing; a schema failure still throws `RECORD_INVALID`.
|
|
714
|
-
|
|
715
|
-
`env_delete`: `confirm` is a required boolean. `false` performs a dry run: it returns
|
|
716
|
-
`deleted: false` plus `blockers` and writes nothing.
|
|
717
|
-
|
|
718
|
-
### 11.4 Output schemas
|
|
719
|
-
|
|
720
|
-
Small results are precise; open records use `additionalProperties: true`.
|
|
721
|
-
|
|
722
|
-
```ts
|
|
723
|
-
const diagnosticSchema = {
|
|
724
|
-
type: 'object', additionalProperties: false,
|
|
725
|
-
properties: {
|
|
726
|
-
severity: { type: 'string', enum: ['info', 'warning', 'error'], required: true },
|
|
727
|
-
code: { type: 'string', required: true },
|
|
728
|
-
message: { type: 'string', required: true },
|
|
729
|
-
file: { type: 'string' },
|
|
730
|
-
path: { type: 'string' },
|
|
731
|
-
},
|
|
732
|
-
}
|
|
733
|
-
const recordSchema = { type: 'object', additionalProperties: true }
|
|
734
|
-
```
|
|
735
|
-
|
|
736
|
-
`env_list` output:
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
{ type: 'object', additionalProperties: false, properties: {
|
|
740
|
-
items: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
741
|
-
kind: { type: 'string', required: true },
|
|
742
|
-
id: { type: 'string', required: true },
|
|
743
|
-
name: { type: 'string', required: true },
|
|
744
|
-
summary: { type: 'string' },
|
|
745
|
-
} } },
|
|
746
|
-
warnings: { type: 'array', required: true, items: diagnosticSchema },
|
|
747
|
-
} }
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
`env_get` output: `{ record: recordSchema, yaml: { type: 'string', required: true } }`.
|
|
751
|
-
|
|
752
|
-
`env_resolve` output: `{ context: recordSchema, warnings: arrayOf(diagnosticSchema) }`.
|
|
753
|
-
|
|
754
|
-
`env_validate` output: counts per kind plus `diagnostics`.
|
|
755
|
-
|
|
756
|
-
### 11.5 Execution flow
|
|
757
|
-
|
|
758
|
-
```text
|
|
759
|
-
execute(args, exec):
|
|
760
|
-
1. assert exec.signal not aborted
|
|
761
|
-
2. resolve target ids (session default when allowed)
|
|
762
|
-
3. call app.<operation>
|
|
763
|
-
4. on EnvManagerError: rethrow
|
|
764
|
-
5. on unexpected error: wrap as INTERNAL_ERROR with a redacted message
|
|
765
|
-
6. return the declared value
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
Approval helper:
|
|
769
|
-
|
|
770
|
-
```ts
|
|
771
|
-
async function requireApproval(exec, toolName: string, reason: string): Promise<void> {
|
|
772
|
-
if (!exec.agent) throw new EnvManagerError('SESSION_CONTEXT_UNAVAILABLE', '...')
|
|
773
|
-
const outcome = await ctx.approval.request({
|
|
774
|
-
agent: exec.agent,
|
|
775
|
-
toolName,
|
|
776
|
-
callId: exec.callId,
|
|
777
|
-
reason,
|
|
778
|
-
displayReason: { en: reason, zh: reasonZh },
|
|
779
|
-
signal: exec.signal,
|
|
780
|
-
})
|
|
781
|
-
if (outcome !== 'allowed-once') throw new EnvManagerError('POLICY_DENIED', '...')
|
|
782
|
-
}
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
The `displayReason` is localized; the `reason` is the stable English fallback.
|
|
786
|
-
|
|
787
|
-
## 12. HTTP transport
|
|
788
|
-
|
|
789
|
-
### 12.1 Route
|
|
790
|
-
|
|
791
|
-
```ts
|
|
792
|
-
ctx.webServer.register({ kind: 'prefix', path: '/env-manager/api', handler })
|
|
793
|
-
```
|
|
794
|
-
|
|
795
|
-
- `POST` only; other methods → 405 with `Allow: POST`.
|
|
796
|
-
- `Content-Type: application/json; charset=utf-8` required.
|
|
797
|
-
- Body limit 2 MiB; exceeding it → 413.
|
|
798
|
-
- Request header `x-env-manager-request: 1` required, otherwise 403.
|
|
799
|
-
- If an `Origin` header is present it must equal `ctx.webServer.getOrigin()`,
|
|
800
|
-
otherwise 403. No CORS headers are ever sent.
|
|
801
|
-
- The handler never reads cookies or credentials. A credential value may appear only in
|
|
802
|
-
the `credential` request body, is forwarded directly to the credential service and is
|
|
803
|
-
never logged or echoed.
|
|
804
|
-
|
|
805
|
-
### 12.2 Envelope
|
|
806
|
-
|
|
807
|
-
Request:
|
|
808
|
-
|
|
809
|
-
```json
|
|
810
|
-
{ "op": "record.save", "payload": { "kind": "environment", "yaml": "id: test-a\n..." } }
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
Success `200`:
|
|
814
|
-
|
|
815
|
-
```json
|
|
816
|
-
{ "ok": true, "data": { ... } }
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
Failure:
|
|
820
|
-
|
|
821
|
-
```json
|
|
822
|
-
{ "ok": false, "error": { "code": "RECORD_INVALID", "message": "...", "details": { ... } } }
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
Status map: `INVALID_ARGUMENT` 400, `POLICY_DENIED`/`FORBIDDEN` 403, `*_NOT_FOUND` 404,
|
|
826
|
-
`RECORD_IN_USE`/`BINDING_CONFLICT`/`DEPLOYMENT_AMBIGUOUS` 409, `RECORD_INVALID`/
|
|
827
|
-
`RULE_*`/`PARAMETER_*` 422, `CREDENTIAL_UNAVAILABLE` 503, anything else 500 (including
|
|
828
|
-
`CREDENTIAL_WRITE_FAILED`). The body always carries the code; the status is for tooling.
|
|
829
|
-
|
|
830
|
-
### 12.3 Operations
|
|
831
|
-
|
|
832
|
-
| op | payload | data |
|
|
833
|
-
|---|---|---|
|
|
834
|
-
| `meta` | — | dataDir, version, counts, warnings |
|
|
835
|
-
| `list` | `{ kind }` | summaries |
|
|
836
|
-
| `get` | `{ kind, id }` | `{ record, yaml }` |
|
|
837
|
-
| `find` | `{ query }` | hits |
|
|
838
|
-
| `save` | `{ kind, record?, yaml?, dryRun? }` | save result |
|
|
839
|
-
| `remove` | `{ kind, id, confirm?, dryRun? }` | remove result |
|
|
840
|
-
| `validate` | — | validation report |
|
|
841
|
-
| `resolve` | `{ environmentId, serviceId, deploymentId? }` | context |
|
|
842
|
-
| `route` | `{ operation, environmentId, serviceId, deploymentId? }` | resolution |
|
|
843
|
-
| `credential` | `{ ref, value }` | `{ ref, configured }` |
|
|
844
|
-
| `credentialUnset` | `{ ref }` | `{ ref, removed }` |
|
|
845
|
-
|
|
846
|
-
The UI cannot read or write session context; that is agent-only in V1 (§9).
|
|
847
|
-
|
|
848
|
-
### 12.4 Actor policy
|
|
849
|
-
|
|
850
|
-
`record.save` and `record.remove` run with actor `'ui'`:
|
|
851
|
-
|
|
852
|
-
- production environment save is denied unless `allowUiProductionWrites`;
|
|
853
|
-
- `record.remove` requires `confirm: true` unless `dryRun: true`;
|
|
854
|
-
- every write is logged with actor, op, kind, id and outcome (no record content).
|
|
855
|
-
|
|
856
|
-
`credential` and `credentialUnset` are UI-only, restricted to the `env-manager/`
|
|
857
|
-
namespace and never write to the registry. They log only the ref and outcome.
|
|
858
|
-
|
|
859
|
-
## 13. Client module
|
|
860
|
-
|
|
861
|
-
Structural contract:
|
|
862
|
-
|
|
863
|
-
```js
|
|
864
|
-
return {
|
|
865
|
-
inject: ['slots', 'locale'],
|
|
866
|
-
apply(ctx) {
|
|
867
|
-
ctx.effect(() => ctx.locale.register('envManager', { en, zh }))
|
|
868
|
-
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
|
|
869
|
-
{ name: 'sidebar.panellist', id: 'env-manager', order: 40 }, Icon))
|
|
870
|
-
ctx.slots.inject('main', () => ctx.slots.register(
|
|
871
|
-
{ name: 'main', key: 'env-manager' }, Panel))
|
|
872
|
-
},
|
|
873
|
-
}
|
|
874
|
-
```
|
|
875
|
-
|
|
876
|
-
- All data access goes through `createApi()` in `client.js`, which posts to
|
|
877
|
-
`/env-manager/api` with the required header and maps failures to a UI error object.
|
|
878
|
-
- The panel keeps state locally (`useState`/`useEffect`); no global store.
|
|
879
|
-
- Rendering and interactions are specified in [`ui-design.md`](ui-design.md).
|
|
880
|
-
|
|
881
|
-
## 14. Memory bridge
|
|
882
|
-
|
|
883
|
-
```ts
|
|
884
|
-
interface MemoryBridge {
|
|
885
|
-
readonly available: boolean
|
|
886
|
-
emit(record: OperationRecord): Promise<void>
|
|
887
|
-
observe(scope: MemoryScope): Promise<readonly Observation[]>
|
|
888
|
-
propose(candidate: CandidateChange): Promise<string> // returns proposal id
|
|
889
|
-
}
|
|
890
|
-
```
|
|
891
|
-
|
|
892
|
-
V1 ships `NullMemoryBridge`: `available: false`, `emit` resolves without writing,
|
|
893
|
-
`observe` returns `[]`, `propose` throws `MEMORY_BRIDGE_UNAVAILABLE`. It is selected
|
|
894
|
-
when no memory provider is registered; a future adapter plugs in without changing the
|
|
895
|
-
tools because nothing in V1 calls `emit` (no execution).
|
|
896
|
-
|
|
897
|
-
## 15. Cross-cutting
|
|
898
|
-
|
|
899
|
-
### 15.1 Logging
|
|
900
|
-
|
|
901
|
-
`src/logger.ts` wraps `ctx.get('logger')` when it is callable, else `console`.
|
|
902
|
-
Log levels: `info` for lifecycle (dataDir, loaded counts), `warn` for skipped records,
|
|
903
|
-
`error` for failed loads. Records and credential refs are logged as ids only; never a
|
|
904
|
-
whole record, never a value.
|
|
905
|
-
|
|
906
|
-
### 15.2 Redaction
|
|
907
|
-
|
|
908
|
-
`redactValue(text)` removes anything that looks like a URL userinfo, a bearer token,
|
|
909
|
-
or a private key block from messages before they reach the model or the UI. Tool
|
|
910
|
-
results never include `credentialRef` values, only configured state.
|
|
911
|
-
|
|
912
|
-
### 15.3 Cancellation
|
|
913
|
-
|
|
914
|
-
`execute` receives `exec.signal`; long loops (registry load, resolve) check it between
|
|
915
|
-
files and between fields. A cancelled call surfaces DSH's own abort error unchanged.
|
|
916
|
-
|
|
917
|
-
### 15.4 Performance budgets
|
|
918
|
-
|
|
919
|
-
| Operation | Budget |
|
|
920
|
-
|---|---|
|
|
921
|
-
| registry load (500 records) | 100 ms |
|
|
922
|
-
| resolve (50 fields) | 5 ms |
|
|
923
|
-
| route with skill snapshot | 50 ms |
|
|
924
|
-
| HTTP round trip (local) | 150 ms |
|
|
925
|
-
|
|
926
|
-
## 16. Test obligations
|
|
927
|
-
|
|
928
|
-
| Module | Tests | From AGENTS §13 |
|
|
929
|
-
|---|---|---|
|
|
930
|
-
| `storage` | atomic write, missing file, invalid YAML, schema mismatch, backups, traversal rejection | required |
|
|
931
|
-
| `registry` | load/lookup, duplicate id, broken references, alias collision | required |
|
|
932
|
-
| `resolver` | precedence, field merge, provenance, undefined/cycle/traversal, runtime selection | required |
|
|
933
|
-
| `router` | each level, conflict, missing skill, source/provider mismatch, policy, parameters | required |
|
|
934
|
-
| `session` | set/get/clear, isolation, corrupt fallback | required |
|
|
935
|
-
| `tools` | output matches schema, error codes, no credential values, approval denial | required |
|
|
936
|
-
| `app` | save→get round trip, delete in use, validate report | new |
|
|
937
|
-
| `http` | auth header, origin, method, size cap, status map, production denial | new |
|
|
938
|
-
| `client` | manual checklist in `ui-design.md` §9 | manual |
|
|
939
|
-
|
|
940
|
-
Tests live in `tests/<module>/*.test.ts`, fixtures in `tests/fixtures/<scenario>/`.
|
|
941
|
-
|
|
942
|
-
## 17. Traceability
|
|
943
|
-
|
|
944
|
-
| Design section | Implements | Acceptance |
|
|
945
|
-
|---|---|---|
|
|
946
|
-
| §4 storage | ADR-0002 | AC-01, AC-02 |
|
|
947
|
-
| §7 resolver | ADR-0005 | AC-03, AC-04 |
|
|
948
|
-
| §8 router | ADR-0003 | AC-05…AC-07 |
|
|
949
|
-
| §9 session | ADR-0007 | AC-08 |
|
|
950
|
-
| §12 HTTP | ADR-0009 | UI manual QA (`ui-design.md` §12) |
|
|
951
|
-
| §11 tools | tool-contract.md | AC-02…AC-08 |
|
|
952
|
-
|
|
953
|
-
## 18. Deliberate omissions
|
|
954
|
-
|
|
955
|
-
- No cache beyond one in-flight load.
|
|
956
|
-
- No cross-process locking.
|
|
957
|
-
- No Skill execution.
|
|
958
|
-
- No session-event projection.
|
|
959
|
-
- No digest pinning.
|
|
960
|
-
- No plugin-side bundler.
|
|
961
|
-
- No database.
|
|
1
|
+
# Detailed Design (V1)
|
|
2
|
+
|
|
3
|
+
> Status: normative implementation design for V1. It refines
|
|
4
|
+
> [`design.md`](design.md) into module-level contracts and depends on the verified
|
|
5
|
+
> runtime facts in [`api-verification.md`](api-verification.md).
|
|
6
|
+
> Record shapes are defined in [`data-model.md`](data-model.md); do not restate or
|
|
7
|
+
> extend them here without updating that document.
|
|
8
|
+
|
|
9
|
+
## 1. Scope
|
|
10
|
+
|
|
11
|
+
This document specifies:
|
|
12
|
+
|
|
13
|
+
1. the plugin configuration and its effective defaults;
|
|
14
|
+
2. every Host module's public interface and internal algorithm;
|
|
15
|
+
3. the exact `defineTool` contract for all ten tools;
|
|
16
|
+
4. the plugin-owned HTTP API used by the Client panel;
|
|
17
|
+
5. error mapping, limits, concurrency and test obligations.
|
|
18
|
+
|
|
19
|
+
UI screens and copy are in [`ui-design.md`](ui-design.md); build and packaging are in
|
|
20
|
+
[`design.md`](design.md) §15 and [`implementation-plan.md`](implementation-plan.md).
|
|
21
|
+
|
|
22
|
+
## 2. Conventions
|
|
23
|
+
|
|
24
|
+
- Host: TypeScript, ESM, `strict`.
|
|
25
|
+
- Runtime validation: Zod. Tool parameter validation: DSH's schema DSL (F3).
|
|
26
|
+
- Files: YAML 1.2 via the `yaml` package; no anchors/aliases are emitted.
|
|
27
|
+
- Time: ISO-8601 UTC with milliseconds, produced by `new Date().toISOString()`.
|
|
28
|
+
- Paths: POSIX separators inside resolved values; OS-native paths only at the
|
|
29
|
+
filesystem boundary.
|
|
30
|
+
- All expected failures are `EnvManagerError extends HarnessError` (F5) with a code
|
|
31
|
+
from `data-model.md` §11.
|
|
32
|
+
|
|
33
|
+
## 3. Configuration
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// src/config.ts
|
|
37
|
+
import z from '@deepseek-ai/schemastery'
|
|
38
|
+
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
dataDir: z.string().description('Registry directory; relative paths resolve against the profile directory.'),
|
|
41
|
+
maxRecords: z.natural().default(2000),
|
|
42
|
+
registerSkill: z.boolean().default(true),
|
|
43
|
+
writePolicy: z.object({
|
|
44
|
+
allowUiProductionWrites: z.boolean().default(false),
|
|
45
|
+
requireApprovalForDelete: z.boolean().default(true),
|
|
46
|
+
requireApprovalForProductionSave: z.boolean().default(true),
|
|
47
|
+
}).default({}),
|
|
48
|
+
defaults: z.object({
|
|
49
|
+
environmentId: z.string().default(''),
|
|
50
|
+
}).default({}),
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Field | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `dataDir` | profile data dir | Registry root; see §4.1 |
|
|
57
|
+
| `maxRecords` | 2000 | hard cap per record kind; exceeding it fails a write |
|
|
58
|
+
| `registerSkill` | true | register the profile-wide `dsh-env-manager` discovery Skill |
|
|
59
|
+
| `writePolicy.allowUiProductionWrites` | false | allow the HTTP API to write production records |
|
|
60
|
+
| `writePolicy.requireApprovalForDelete` | true | `env_delete` asks the human first |
|
|
61
|
+
| `writePolicy.requireApprovalForProductionSave` | true | `env_save` on a production environment asks first |
|
|
62
|
+
| `defaults.environmentId` | empty | fallback when neither an argument nor the session default exists |
|
|
63
|
+
|
|
64
|
+
Rules:
|
|
65
|
+
|
|
66
|
+
- The config schema is schemastery (F1/F18), not Zod. Do not add a second config path.
|
|
67
|
+
- Config is read-only at runtime; there is no write API. The Client shows effective
|
|
68
|
+
values only.
|
|
69
|
+
- A blank `dataDir` means "use the resolution order in §4.1", not "current directory".
|
|
70
|
+
|
|
71
|
+
## 4. Storage
|
|
72
|
+
|
|
73
|
+
### 4.1 Data directory resolution
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
1. config.dataDir, if set:
|
|
77
|
+
absolute -> use as-is
|
|
78
|
+
relative -> path.resolve(profileDir, config.dataDir)
|
|
79
|
+
2. profileDir from ctx.get('profileContext')?.dir
|
|
80
|
+
-> <profileDir>/data/dsh-env-manager
|
|
81
|
+
3. process.env.DSH_PROFILE_DIR
|
|
82
|
+
-> <DSH_PROFILE_DIR>/data/dsh-env-manager
|
|
83
|
+
4. process.cwd()/data/dsh-env-manager
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`profileDir` may be unavailable in an embedded host; step 3 and 4 keep the plugin
|
|
87
|
+
usable. The resolved absolute path is memoized for the process lifetime, logged once,
|
|
88
|
+
and reported by `env_get_context` and the UI overview. It is never derived from model
|
|
89
|
+
input.
|
|
90
|
+
|
|
91
|
+
### 4.2 Layout
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
<dataDir>/
|
|
95
|
+
├── environments/<environmentId>.yaml
|
|
96
|
+
├── services/<serviceId>.yaml
|
|
97
|
+
├── deployments/<deploymentId>.yaml
|
|
98
|
+
├── rules/<ruleProfileId>.yaml
|
|
99
|
+
├── operations/<bindingId>.yaml
|
|
100
|
+
├── sessions/<sessionId>.json
|
|
101
|
+
└── .backups/<kind>/<id>/<timestamp>.yaml (rolling, max 5)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- The file name is always `<id>.yaml`; the record `id` must equal the file stem.
|
|
105
|
+
- `sessions/` and `.backups/` are never scanned as registry records.
|
|
106
|
+
- Directories are created lazily by the first write; a missing root on read yields an
|
|
107
|
+
empty registry, not an error.
|
|
108
|
+
|
|
109
|
+
### 4.3 Id and path safety
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`resolveRecordPath(kind, id)` rejects a non-matching id **before** touching the
|
|
116
|
+
filesystem, then verifies `path.resolve(dir, id + '.yaml')` starts with
|
|
117
|
+
`path.resolve(dir) + path.sep`. Session ids are validated as `[A-Za-z0-9_-]{1,128}`
|
|
118
|
+
and hashed only if they fail that pattern.
|
|
119
|
+
|
|
120
|
+
### 4.4 Atomic write protocol
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
async function writeRecord(kind, id, record) {
|
|
124
|
+
const target = resolveRecordPath(kind, id)
|
|
125
|
+
await mkdir(dirname(target), { recursive: true })
|
|
126
|
+
const tmp = join(dirname(target), `.${id}.${process.pid}.${randomUUID()}.tmp`)
|
|
127
|
+
const text = stringifyYaml(record)
|
|
128
|
+
const handle = await open(tmp, 'w')
|
|
129
|
+
try {
|
|
130
|
+
await handle.writeFile(text, 'utf8')
|
|
131
|
+
await handle.sync()
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close()
|
|
134
|
+
}
|
|
135
|
+
await rotateBackup(kind, id, target) // copy previous file, prune to 5
|
|
136
|
+
await rename(tmp, target) // atomic replace on POSIX and Windows
|
|
137
|
+
return { file: target, bytes: Buffer.byteLength(text) }
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Guarantees:
|
|
142
|
+
|
|
143
|
+
- A reader never sees a partially written record.
|
|
144
|
+
- A crash before `rename` leaves an ignorable `.tmp` file; startup cleanup removes
|
|
145
|
+
`*.tmp` older than one hour.
|
|
146
|
+
- Backup failure does not block the write; it is reported as a warning.
|
|
147
|
+
- `writeRecord` is serialized per target path by an in-process promise chain (§4.8).
|
|
148
|
+
|
|
149
|
+
### 4.5 Read protocol
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
async function readRecord(kind, id): Promise<ReadResult> {
|
|
153
|
+
const target = resolveRecordPath(kind, id)
|
|
154
|
+
let text
|
|
155
|
+
try { text = await readFile(target, 'utf8') }
|
|
156
|
+
catch (e) { if (e.code === 'ENOENT') return { found: false }; throw e }
|
|
157
|
+
const parsed = parseYamlDocument(text) // throws RECORD_INVALID
|
|
158
|
+
return { found: true, raw: parsed, text }
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Invalid YAML, a non-object document, an id/filename mismatch, or a schema failure
|
|
163
|
+
produces a `Diagnostic` and the record is treated as absent for that request. The file
|
|
164
|
+
is never rewritten or deleted.
|
|
165
|
+
|
|
166
|
+
Unknown keys are preserved on read and reported as an `UNKNOWN_FIELD` warning, but
|
|
167
|
+
`env_save` rejects them (`RECORD_INVALID`) so a typo cannot enter the store. Storage
|
|
168
|
+
therefore parses twice: `.passthrough()` on read, strict on save.
|
|
169
|
+
|
|
170
|
+
### 4.6 Delete
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
1. Resolve the target and confirm it exists.
|
|
174
|
+
2. Ask the registry whether any record references it (§6.3).
|
|
175
|
+
3. If referenced -> RECORD_IN_USE listing referrers.
|
|
176
|
+
4. Otherwise rotate a backup, then unlink the file.
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Deleting a session file is a plain unlink and needs no reference check.
|
|
180
|
+
|
|
181
|
+
### 4.7 Session files
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"schemaVersion": 1,
|
|
186
|
+
"sessionId": "01J...",
|
|
187
|
+
"defaultEnvironmentId": "test-a",
|
|
188
|
+
"updatedAt": "2026-11-01T03:00:00.000Z"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Written with the same temp+rename protocol. A corrupt or unreadable file is reported
|
|
193
|
+
as `persisted: false` and treated as "no default"; it is not deleted.
|
|
194
|
+
|
|
195
|
+
### 4.8 Concurrency
|
|
196
|
+
|
|
197
|
+
- One serialized chain per file path (`Map<string, Promise<void>>`), released when the
|
|
198
|
+
chain drains.
|
|
199
|
+
- Registry loads are deduplicated: concurrent callers share one in-flight load.
|
|
200
|
+
- No cross-process lock in V1. Two DSH processes writing the same file can lose one
|
|
201
|
+
update; the backup makes the loss recoverable. Documented as a limitation.
|
|
202
|
+
|
|
203
|
+
### 4.9 Limits
|
|
204
|
+
|
|
205
|
+
| Limit | Value | Failure |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| records per kind | `maxRecords` (2000) | `RECORD_LIMIT_EXCEEDED` |
|
|
208
|
+
| record YAML bytes | 1 MiB | `RECORD_TOO_LARGE` |
|
|
209
|
+
| session file bytes | 64 KiB | `SESSION_CONTEXT_UNPERSISTED` |
|
|
210
|
+
|
|
211
|
+
## 5. Domain
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// src/domain/types.ts
|
|
215
|
+
export type RecordKind = 'environment' | 'service' | 'deployment' | 'ruleProfile' | 'operationBinding'
|
|
216
|
+
export type AnyRecord = Environment | Service | Deployment | RuleProfile | OperationBinding
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`src/domain/schemas.ts` exports one Zod schema per record plus:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
export function parseRecord(kind: RecordKind, value: unknown, origin: string):
|
|
223
|
+
{ ok: true; record: AnyRecord } | { ok: false; diagnostic: Diagnostic }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Validation responsibilities:
|
|
227
|
+
|
|
228
|
+
- `RecordMeta`: id pattern, `schemaVersion === 1`, ISO timestamps, `updatedAt >= createdAt`.
|
|
229
|
+
- Cross-field rules from `data-model.md`: at least one `deployModes`, host role ids
|
|
230
|
+
exist, `location.kind === runtimeMode`, unique host id / ip / cluster id /
|
|
231
|
+
endpoint id, unique account id per host, `loginRecords[].hostRef`/`accountId`
|
|
232
|
+
resolve within the environment, host `clusterRef` resolves and is present for
|
|
233
|
+
`k8s-all-in-one`/`k8s-worker`, `access` values are unique, `paths` keys and variable
|
|
234
|
+
names match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
235
|
+
- `structural` shape only. Referential integrity is the registry's job (§6.3), because
|
|
236
|
+
it needs the whole snapshot.
|
|
237
|
+
|
|
238
|
+
`parseRecord` never throws for bad data; it returns a diagnostic with `path` pointing
|
|
239
|
+
at the failing field.
|
|
240
|
+
|
|
241
|
+
Tool input is parsed by `parseRecordInput`, which is identical except `createdAt` and
|
|
242
|
+
`updatedAt` are optional. The app then calls `finalizeRecord(record, previous?)` to
|
|
243
|
+
stamp them before `parseRecord` and before writing.
|
|
244
|
+
|
|
245
|
+
## 6. Registry
|
|
246
|
+
|
|
247
|
+
### 6.1 Snapshot
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
interface RegistrySnapshot {
|
|
251
|
+
environments: Map<string, Environment>
|
|
252
|
+
services: Map<string, Service>
|
|
253
|
+
deployments: Map<string, Deployment>
|
|
254
|
+
ruleProfiles: Map<string, RuleProfile>
|
|
255
|
+
operationBindings: Map<string, OperationBinding>
|
|
256
|
+
byAlias: Map<string, string> // "service:<alias>" -> id
|
|
257
|
+
diagnostics: Diagnostic[]
|
|
258
|
+
loadedAt: string
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`byAlias` keys are `${kind}:${alias}` and resolve aliases for `env_get`/`env_find`.
|
|
263
|
+
Duplicate aliases are a warning; the first record wins, sorted by file name for
|
|
264
|
+
determinism.
|
|
265
|
+
|
|
266
|
+
### 6.2 Load algorithm
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
for each kind directory (environments, services, deployments, rules, operations):
|
|
270
|
+
entries = readdir(dir, { withFileTypes: true })
|
|
271
|
+
files = entries where isFile() and name endsWith('.yaml') and not name startsWith('.')
|
|
272
|
+
sort files by name
|
|
273
|
+
for each file:
|
|
274
|
+
read + parse
|
|
275
|
+
parseRecord -> on failure: diagnostic (file, code, path, message); skip
|
|
276
|
+
if map already has record.id: diagnostic DUPLICATE_ID; skip later file
|
|
277
|
+
else insert
|
|
278
|
+
refresh byAlias and run integrity checks
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A missing kind directory is normal (empty). An unreadable directory is an error
|
|
282
|
+
diagnostic, and the snapshot keeps the other kinds.
|
|
283
|
+
|
|
284
|
+
### 6.3 Referential integrity
|
|
285
|
+
|
|
286
|
+
| Reference | Rule | Code |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| `deployment.environmentId` | environment exists | `ENV_NOT_FOUND` |
|
|
289
|
+
| `deployment.serviceId` | service exists | `SERVICE_NOT_FOUND` |
|
|
290
|
+
| `deployment.location.hostRef` | host exists in that environment | `HOST_NOT_FOUND` |
|
|
291
|
+
| `deployment.location.clusterRef` | cluster exists in that environment | `CLUSTER_NOT_FOUND` |
|
|
292
|
+
| environment `hosts[].clusterRef` | cluster exists; required for K8s node kinds | `RECORD_INVALID` |
|
|
293
|
+
| environment `endpoints[].hostRef` / `clusterRef` | target exists in that environment | `RECORD_INVALID` |
|
|
294
|
+
| `service.ruleProfileRefs[]` | rule profile exists | `RULE_PROFILE_NOT_FOUND` |
|
|
295
|
+
| operation binding `match.environmentId` | environment exists | `ENV_NOT_FOUND` |
|
|
296
|
+
| operation binding `match.serviceId` | service exists | `SERVICE_NOT_FOUND` |
|
|
297
|
+
| operation binding `match.deploymentId` | deployment exists | `DEPLOYMENT_NOT_FOUND` |
|
|
298
|
+
| any `credentialRef` | non-empty string matching the agreed ref pattern | `RECORD_INVALID` |
|
|
299
|
+
|
|
300
|
+
Schema validation rejects an environment with zero logical roles and a host with zero
|
|
301
|
+
roles before integrity checks run; both fail as `RECORD_INVALID`. A host role id that is
|
|
302
|
+
not declared in `Environment.roles` is reported as `ROLE_NOT_FOUND` by `env_validate`;
|
|
303
|
+
the panel prevents selecting an undeclared role in the node form.
|
|
304
|
+
|
|
305
|
+
Integrity failures are **diagnostics**, not load failures: the registry still serves
|
|
306
|
+
the other records, and `env_validate` reports the broken references. A resolve or
|
|
307
|
+
route that needs a missing record fails with the specific code.
|
|
308
|
+
|
|
309
|
+
### 6.4 Reload policy
|
|
310
|
+
|
|
311
|
+
V1 reloads the registry at the start of every application request. Rationale: users
|
|
312
|
+
edit YAML by hand, and a stale cache is worse than a few milliseconds. Concurrent
|
|
313
|
+
requests share one in-flight load. A snapshot is replaced only after a successful
|
|
314
|
+
load; a load that throws keeps the previous snapshot and reports an error diagnostic.
|
|
315
|
+
|
|
316
|
+
Performance target: 500 records load in under 100 ms on a local SSD. If that is
|
|
317
|
+
missed, add an mtime-keyed cache in a follow-up, not in V1.
|
|
318
|
+
|
|
319
|
+
## 7. Resolver
|
|
320
|
+
|
|
321
|
+
### 7.1 Template parsing
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
type TemplatePart =
|
|
325
|
+
| { kind: 'literal'; text: string }
|
|
326
|
+
| { kind: 'ref'; path: string }
|
|
327
|
+
|
|
328
|
+
function parseTemplate(text: string): TemplatePart[]
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Scanner rules:
|
|
332
|
+
|
|
333
|
+
1. Find `${`. Text before it is a literal.
|
|
334
|
+
2. Find the next `}`. A missing `}` is `RULE_TEMPLATE_INVALID`.
|
|
335
|
+
3. The inner text must match `^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`;
|
|
336
|
+
otherwise `RULE_TEMPLATE_INVALID`.
|
|
337
|
+
4. Anything that is not `${…}` is literal text, including a lone `$`.
|
|
338
|
+
5. No nesting, escaping, calls, operators, or env access.
|
|
339
|
+
6. Template length is capped at 2048 characters.
|
|
340
|
+
|
|
341
|
+
### 7.2 Namespaces
|
|
342
|
+
|
|
343
|
+
| Reference | Resolves to |
|
|
344
|
+
|---|---|
|
|
345
|
+
| `env.id`, `env.name`, `env.category` | environment record |
|
|
346
|
+
| `service.id`, `service.name`, `service.category` | service record |
|
|
347
|
+
| `service.artifact.name` | service artifact name, when declared |
|
|
348
|
+
| `deployment.id`, `deployment.name`, `deployment.runtimeMode` | deployment record |
|
|
349
|
+
| `deployment.location.<field>` | deployment location, runtime-specific |
|
|
350
|
+
| `<variable>` | layer variable of that name |
|
|
351
|
+
| `variables.<variable>` | same, explicit form |
|
|
352
|
+
| `paths.<pathName>` | another resolved path |
|
|
353
|
+
|
|
354
|
+
`service.artifact.name` resolves only when `artifact` exists. A reference to a
|
|
355
|
+
namespace that is unavailable for this context is `RULE_UNDEFINED_VARIABLE`, not an
|
|
356
|
+
empty string.
|
|
357
|
+
|
|
358
|
+
### 7.3 Layers
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
interface Layer {
|
|
362
|
+
source: RuleSource
|
|
363
|
+
variables?: Record<string, string | number | boolean>
|
|
364
|
+
paths?: Record<string, string>
|
|
365
|
+
logSources?: Partial<Record<RuntimeMode, LogSource>>
|
|
366
|
+
classLocations?: Partial<Record<RuntimeMode, ClassLocation>>
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Built from `design.md` §6.3:
|
|
371
|
+
|
|
372
|
+
1. built-in global defaults (empty in V1);
|
|
373
|
+
2. each `service.ruleProfileRefs` entry, in order;
|
|
374
|
+
3. `environment.ruleOverrides`;
|
|
375
|
+
4. `deployment.variables` (variables only), then `deployment.ruleOverrides`.
|
|
376
|
+
|
|
377
|
+
Flatten to a field map:
|
|
378
|
+
|
|
379
|
+
```text
|
|
380
|
+
variables.<name>
|
|
381
|
+
paths.<name>
|
|
382
|
+
logSources.<runtime>.<property>
|
|
383
|
+
classLocations.<runtime>.<property>
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### 7.4 Merge
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
interface MergedField {
|
|
390
|
+
key: string
|
|
391
|
+
raw: string | number | boolean
|
|
392
|
+
definedBy: RuleSource
|
|
393
|
+
shadowed: RuleSource[]
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Iterate layers in ascending order. For each field, replace the winner and append the
|
|
398
|
+
previous winner to `shadowed`. This is the only merge rule; there is no deep merge and
|
|
399
|
+
no array concatenation.
|
|
400
|
+
|
|
401
|
+
### 7.5 Evaluation
|
|
402
|
+
|
|
403
|
+
```text
|
|
404
|
+
1. Build the reference graph: node = merged field key; edge = each ref used by a
|
|
405
|
+
string field. Constants (env/service/deployment) are not nodes.
|
|
406
|
+
2. DFS with an active stack. A back edge is RULE_CYCLE with the full cycle path.
|
|
407
|
+
3. A ref with no node and no constant is RULE_UNDEFINED_VARIABLE
|
|
408
|
+
(message names the field and the ref).
|
|
409
|
+
4. Resolve nodes in post-order; substitute into an ordered list of parts.
|
|
410
|
+
5. For every resolved `paths.*` value, split on `/` and reject `..`
|
|
411
|
+
(RULE_PATH_TRAVERSAL). Absolute/relative shape is otherwise preserved.
|
|
412
|
+
6. Convert non-string numbers/booleans to their literal text when interpolated.
|
|
413
|
+
7. Select `logSources[runtimeMode]` and `classLocations[runtimeMode]` after their
|
|
414
|
+
strings resolve.
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Cycle detection is iterative (explicit stack), not recursive, so a pathological graph
|
|
418
|
+
cannot overflow the JS stack.
|
|
419
|
+
|
|
420
|
+
### 7.6 Provenance
|
|
421
|
+
|
|
422
|
+
For every requested field:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
interface ProvenanceEntry {
|
|
426
|
+
field: string
|
|
427
|
+
value: string | number | boolean
|
|
428
|
+
definedBy: RuleSource
|
|
429
|
+
inputs: Array<{ field: string; source: RuleSource }>
|
|
430
|
+
shadowed: RuleSource[]
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`inputs` records the source of the winning definition of each referenced field, so a
|
|
435
|
+
value that depends on an environment override names the environment layer. `shadowed`
|
|
436
|
+
comes from the merge.
|
|
437
|
+
|
|
438
|
+
`paths` and `variables` produce one entry per name. `logSource` and `classLocation` are
|
|
439
|
+
structured: each resolved property produces its own entry
|
|
440
|
+
(`logSources.kubernetes.namespace`), returned as a list so a mixed-layer object is still
|
|
441
|
+
explainable.
|
|
442
|
+
|
|
443
|
+
### 7.7 Public API
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
interface ResolveInput {
|
|
447
|
+
environment: Environment
|
|
448
|
+
service: Service
|
|
449
|
+
deployment?: Deployment
|
|
450
|
+
ruleProfiles: Map<string, RuleProfile>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function resolveContext(input: ResolveInput): ResolveResult
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`ResolveResult` is `{ ok: true; context: ServiceContext } | { ok: false; diagnostics: Diagnostic[] }`.
|
|
457
|
+
The function is pure; it never reads files, time, or config.
|
|
458
|
+
|
|
459
|
+
## 8. Router
|
|
460
|
+
|
|
461
|
+
### 8.1 Eligibility
|
|
462
|
+
|
|
463
|
+
A binding is eligible when:
|
|
464
|
+
|
|
465
|
+
- `enabled === true`;
|
|
466
|
+
- `operation` equals the requested id exactly;
|
|
467
|
+
- every declared `match` field matches the context:
|
|
468
|
+
|
|
469
|
+
| Field | Matches |
|
|
470
|
+
|---|---|
|
|
471
|
+
| `environmentId` | context environment id |
|
|
472
|
+
| `serviceId` | context service id |
|
|
473
|
+
| `deploymentId` | context deployment id (requires a deployment) |
|
|
474
|
+
| `deploymentMode` | context runtime mode |
|
|
475
|
+
| `serviceCategory` | service category |
|
|
476
|
+
|
|
477
|
+
An empty `match` matches everything.
|
|
478
|
+
|
|
479
|
+
A binding whose `match` is not one of the five documented shapes is `RECORD_INVALID` at
|
|
480
|
+
load (data-model §7); the router never sees such a binding.
|
|
481
|
+
|
|
482
|
+
### 8.2 Level and selection
|
|
483
|
+
|
|
484
|
+
Rank: `deployment` 1, `environment-service` 2, `service` 3, `category-mode` 4,
|
|
485
|
+
`global` 5. Level is derived from which match fields are present, with
|
|
486
|
+
`deploymentId` winning over `environmentId`+`serviceId`, which wins over `serviceId`,
|
|
487
|
+
then `serviceCategory`+`deploymentMode`, then empty.
|
|
488
|
+
|
|
489
|
+
```text
|
|
490
|
+
eligible = filter(bindings, eligible)
|
|
491
|
+
if eligible is empty: BINDING_NOT_FOUND
|
|
492
|
+
top = min(rank)
|
|
493
|
+
winners = eligible where rank == top
|
|
494
|
+
if winners.length > 1: BINDING_CONFLICT (list every candidate)
|
|
495
|
+
else selected = winners[0]
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Lower levels are ignored once a winner exists, even if they also match.
|
|
499
|
+
|
|
500
|
+
### 8.3 Skill availability
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
interface SkillDirectory {
|
|
504
|
+
snapshot(opts): Promise<{ skills: SkillSummary[]; complete: boolean }>
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Adapter over `ctx.skills` (F20):
|
|
509
|
+
|
|
510
|
+
| Condition | Result |
|
|
511
|
+
|---|---|
|
|
512
|
+
| `ctx.skills` absent | `available: 'unknown'` + `SKILL_REGISTRY_UNAVAILABLE` warning, `ok: true` |
|
|
513
|
+
| `complete === false` | same |
|
|
514
|
+
| name not found | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
515
|
+
| `expectedSource` set and `source` differs | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
516
|
+
| `expectedProvider` set and `provider` differs | `ok: false`, `SKILL_UNAVAILABLE` |
|
|
517
|
+
| otherwise | `available: true` |
|
|
518
|
+
|
|
519
|
+
Digest pinning is not supported: DSH's catalog exposes no digest. The record field is
|
|
520
|
+
removed in this design revision; do not reintroduce it without a provider that can
|
|
521
|
+
verify it.
|
|
522
|
+
|
|
523
|
+
### 8.4 Policy
|
|
524
|
+
|
|
525
|
+
```text
|
|
526
|
+
write = operation in WRITE_OPERATIONS
|
|
527
|
+
requiresApproval = policy.requiresApproval ?? write
|
|
528
|
+
if context.environmentCategory === 'production' and policy.allowProduction !== true:
|
|
529
|
+
POLICY_DENIED
|
|
530
|
+
else: return policy
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
`requiresApproval` and `allowProduction` are returned to the caller; V1 does not
|
|
534
|
+
execute the Skill. A future executor must enforce them, not just display them.
|
|
535
|
+
|
|
536
|
+
### 8.5 Parameters
|
|
537
|
+
|
|
538
|
+
- Strings are templates resolved through the same engine, with the context namespaces
|
|
539
|
+
and `paths.*`.
|
|
540
|
+
- Numbers and booleans are stringified.
|
|
541
|
+
- A null, array, or object value is `PARAMETER_RESOLUTION_FAILED`.
|
|
542
|
+
- At most 50 parameters; keys must match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
543
|
+
|
|
544
|
+
### 8.6 Public API
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
function resolveOperation(input: {
|
|
548
|
+
operation: string
|
|
549
|
+
context: ServiceContext
|
|
550
|
+
bindings: OperationBinding[]
|
|
551
|
+
skills: SkillDirectory | null
|
|
552
|
+
}): OperationResolution
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Pure except for `skills.snapshot()`, which is an injected async capability. Tests use
|
|
556
|
+
a fake directory.
|
|
557
|
+
|
|
558
|
+
## 9. Session store
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
interface SessionContext {
|
|
562
|
+
sessionId: string
|
|
563
|
+
defaultEnvironmentId: string | null
|
|
564
|
+
persisted: boolean
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
- `get(sessionId)`: read `sessions/<id>.json`; missing → `null` default; corrupt →
|
|
569
|
+
`persisted: false`.
|
|
570
|
+
- `set(sessionId, environmentId)`: validate the environment exists, then write the
|
|
571
|
+
file; a write failure returns `persisted: false` and `SESSION_CONTEXT_UNPERSISTED`.
|
|
572
|
+
- `set(..., null)` writes `defaultEnvironmentId: null` (an explicit clear).
|
|
573
|
+
- Two sessions never share a file.
|
|
574
|
+
- A process-local `Map<sessionId, string | null>` caches the last value. `get` prefers
|
|
575
|
+
the file and falls back to the cache when the file is missing or corrupt; a failed
|
|
576
|
+
write still updates the cache so the current process honors the choice. The cache is
|
|
577
|
+
not durable and is empty after a restart.
|
|
578
|
+
|
|
579
|
+
## 10. Application service
|
|
580
|
+
|
|
581
|
+
`src/app/app.ts` is the single place where storage, registry, resolver, router and
|
|
582
|
+
session are composed. Tools and HTTP both call it; neither contains business logic.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
interface App {
|
|
586
|
+
meta(): Promise<MetaResult>
|
|
587
|
+
list(kind: RecordKind): Promise<ListResult>
|
|
588
|
+
get(kind: RecordKind, id: string): Promise<GetResult>
|
|
589
|
+
find(query: string): Promise<FindResult>
|
|
590
|
+
save(kind: RecordKind, record: unknown, options?: SaveOptions): Promise<SaveResult>
|
|
591
|
+
saveYaml(kind: RecordKind, yaml: string, options?: SaveOptions): Promise<SaveResult>
|
|
592
|
+
remove(kind: RecordKind, id: string, actor: Actor, options?: RemoveOptions): Promise<RemoveResult>
|
|
593
|
+
validate(): Promise<ValidationReport>
|
|
594
|
+
resolve(input: ResolveRequest): Promise<ResolveResult>
|
|
595
|
+
route(input: RouteRequest): Promise<OperationResolution>
|
|
596
|
+
setCredential(ref: string, value: string): Promise<{ ref: string; configured: true }>
|
|
597
|
+
unsetCredential(ref: string): Promise<{ ref: string; removed: true }>
|
|
598
|
+
getContext(sessionId: string): Promise<SessionContext>
|
|
599
|
+
setContext(sessionId: string, environmentId: string | null): Promise<SessionContext>
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface SaveOptions { dryRun?: boolean }
|
|
603
|
+
interface RemoveOptions { dryRun?: boolean }
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Rules:
|
|
607
|
+
|
|
608
|
+
- `save` validates with Zod, sets `createdAt` (create only) and `updatedAt`, checks the
|
|
609
|
+
record cap, writes, then reloads the registry. Timestamps supplied by the caller are
|
|
610
|
+
ignored; the stored record always carries both.
|
|
611
|
+
- A schema failure throws `RECORD_INVALID` with
|
|
612
|
+
`details.issues: [{ path, message }]`, where `path` is a record-relative field path
|
|
613
|
+
(`hosts[1].ip`). The UI renders these as inline diagnostics.
|
|
614
|
+
- `save` returns `created: boolean` by comparing existence before the write.
|
|
615
|
+
- `save` re-runs integrity checks after the write and returns warnings for broken
|
|
616
|
+
references; it does not roll back, because the record is individually valid.
|
|
617
|
+
- `remove` refuses when referenced (`RECORD_IN_USE`) and supports a dry-run flag used
|
|
618
|
+
by the UI's confirmation dialog.
|
|
619
|
+
- `resolve` and `route` take explicit ids; the **tool layer** supplies the session
|
|
620
|
+
default when the model omits an id.
|
|
621
|
+
- `Actor` is `'agent' | 'ui'`; the UI actor cannot write production unless
|
|
622
|
+
`writePolicy.allowUiProductionWrites` is true.
|
|
623
|
+
- `resolve` fills `context.credentials` through an injected
|
|
624
|
+
`CredentialDirectory { describe(ref); set(ref, value); unset(ref) }`. The Host
|
|
625
|
+
adapter calls `ctx.credentials.describe`; tests use a fake. A missing ref yields
|
|
626
|
+
`{ ref, configured: false }` plus `CREDENTIAL_MISSING`; no value is ever requested.
|
|
627
|
+
- `setCredential` accepts only `env-manager/<environmentId>/<accountId>` refs and a
|
|
628
|
+
non-empty value, forwards them to `ctx.credentials.set`, and returns only
|
|
629
|
+
`{ ref, configured: true }`. It never writes the value to a record or log.
|
|
630
|
+
`unsetCredential` uses the same namespace check and calls `ctx.credentials.unset`.
|
|
631
|
+
A missing service yields `CREDENTIAL_UNAVAILABLE`; provider failures yield
|
|
632
|
+
`CREDENTIAL_WRITE_FAILED` without exposing the provider message to the UI.
|
|
633
|
+
|
|
634
|
+
## 11. Tools
|
|
635
|
+
|
|
636
|
+
### 11.1 Shared shape
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
function toolOutput<T extends JsonSchemaNode>(schema: T, render: Renderer): ToolOutputDefinition
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
- Every tool returns a plain object matching its declared schema; DSH validates it.
|
|
643
|
+
- Every failure throws `EnvManagerError`; no tool returns `{ ok: false }`.
|
|
644
|
+
- `render` emits one text block: a one-line summary plus the essential ids. The
|
|
645
|
+
canonical JSON value is what the model receives.
|
|
646
|
+
|
|
647
|
+
`EnvManagerError`:
|
|
648
|
+
|
|
649
|
+
```ts
|
|
650
|
+
class EnvManagerError extends HarnessError {
|
|
651
|
+
constructor(code: ErrorCode, message: string, details?: JsonValue) {
|
|
652
|
+
super(message, code)
|
|
653
|
+
this.details = details
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### 11.2 Tool table
|
|
659
|
+
|
|
660
|
+
| Tool | Parameters | Returns | Session | Approval |
|
|
661
|
+
|---|---|---|---|---|
|
|
662
|
+
| `env_list` | `kind?`, `query?` | `{ items, warnings }` | no | no |
|
|
663
|
+
| `env_get` | `kind`, `id` | `{ record, yaml }` | no | no |
|
|
664
|
+
| `env_find` | `query` | `{ hits }` | no | no |
|
|
665
|
+
| `env_resolve` | `environmentId?`, `serviceId`, `deploymentId?` | `{ context, warnings }` | default env | no |
|
|
666
|
+
| `env_resolve_operation` | `operation`, `environmentId?`, `serviceId?`, `deploymentId?` | `{ resolution }` | yes | no |
|
|
667
|
+
| `env_get_context` | — | `{ sessionId, defaultEnvironmentId, persisted }` | yes | no |
|
|
668
|
+
| `env_set_context` | `environmentId \| null` | same | yes | no |
|
|
669
|
+
| `env_save` | `kind`, `record`, `dryRun?` | `{ id, kind, created, dryRun, warnings }` | no | production env |
|
|
670
|
+
| `env_delete` | `kind`, `id`, `confirm` | `{ id, kind, deleted, blockers }` | no | yes |
|
|
671
|
+
| `env_validate` | — | `{ ok, counts, diagnostics }` | no | no |
|
|
672
|
+
|
|
673
|
+
### 11.3 Parameter definitions
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const kindSchema = {
|
|
677
|
+
type: 'string',
|
|
678
|
+
enum: ['environment', 'service', 'deployment', 'ruleProfile', 'operationBinding'],
|
|
679
|
+
description: 'Record kind.',
|
|
680
|
+
} as const
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
`env_list`:
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
parameters: {
|
|
687
|
+
kind: { ...kindSchema, required: false },
|
|
688
|
+
query: { type: 'string', required: false, description: 'Case-insensitive match over id, name and alias.' },
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`env_get`: `kind` and `id` required, `id` description names the id pattern.
|
|
693
|
+
|
|
694
|
+
`env_find`: `query` required, min length 2.
|
|
695
|
+
|
|
696
|
+
`env_resolve`: `serviceId` required; `environmentId` and `deploymentId` optional.
|
|
697
|
+
Resolving an environment/service pair with more than one deployment and no
|
|
698
|
+
`deploymentId` returns `DEPLOYMENT_AMBIGUOUS` listing candidates; zero deployments
|
|
699
|
+
returns `DEPLOYMENT_NOT_FOUND`.
|
|
700
|
+
|
|
701
|
+
`env_resolve_operation`: `operation` required, `enum` of the 13 standard ids plus a
|
|
702
|
+
free-form custom id — model-visible enum lists only the standard ids; a custom id is
|
|
703
|
+
rejected by the schema unless it is declared in a binding. Because the DSL cannot
|
|
704
|
+
express "either enum or binding-declared", the parameter is a plain string with the
|
|
705
|
+
standard ids in the description and is validated in `execute`
|
|
706
|
+
(`OPERATION_UNKNOWN` when neither standard nor bound).
|
|
707
|
+
|
|
708
|
+
`env_set_context`: `environmentId` is `oneOf` string / `null`.
|
|
709
|
+
|
|
710
|
+
`env_save`: `record` is `{ type: 'json', required: true, description: 'Complete record object.' }`.
|
|
711
|
+
Zod still validates it; the JSON parameter only avoids duplicating every record schema
|
|
712
|
+
in the model-facing tool. `dryRun: true` validates and returns the same shape without
|
|
713
|
+
writing; a schema failure still throws `RECORD_INVALID`.
|
|
714
|
+
|
|
715
|
+
`env_delete`: `confirm` is a required boolean. `false` performs a dry run: it returns
|
|
716
|
+
`deleted: false` plus `blockers` and writes nothing.
|
|
717
|
+
|
|
718
|
+
### 11.4 Output schemas
|
|
719
|
+
|
|
720
|
+
Small results are precise; open records use `additionalProperties: true`.
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
const diagnosticSchema = {
|
|
724
|
+
type: 'object', additionalProperties: false,
|
|
725
|
+
properties: {
|
|
726
|
+
severity: { type: 'string', enum: ['info', 'warning', 'error'], required: true },
|
|
727
|
+
code: { type: 'string', required: true },
|
|
728
|
+
message: { type: 'string', required: true },
|
|
729
|
+
file: { type: 'string' },
|
|
730
|
+
path: { type: 'string' },
|
|
731
|
+
},
|
|
732
|
+
}
|
|
733
|
+
const recordSchema = { type: 'object', additionalProperties: true }
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
`env_list` output:
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
740
|
+
items: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
741
|
+
kind: { type: 'string', required: true },
|
|
742
|
+
id: { type: 'string', required: true },
|
|
743
|
+
name: { type: 'string', required: true },
|
|
744
|
+
summary: { type: 'string' },
|
|
745
|
+
} } },
|
|
746
|
+
warnings: { type: 'array', required: true, items: diagnosticSchema },
|
|
747
|
+
} }
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
`env_get` output: `{ record: recordSchema, yaml: { type: 'string', required: true } }`.
|
|
751
|
+
|
|
752
|
+
`env_resolve` output: `{ context: recordSchema, warnings: arrayOf(diagnosticSchema) }`.
|
|
753
|
+
|
|
754
|
+
`env_validate` output: counts per kind plus `diagnostics`.
|
|
755
|
+
|
|
756
|
+
### 11.5 Execution flow
|
|
757
|
+
|
|
758
|
+
```text
|
|
759
|
+
execute(args, exec):
|
|
760
|
+
1. assert exec.signal not aborted
|
|
761
|
+
2. resolve target ids (session default when allowed)
|
|
762
|
+
3. call app.<operation>
|
|
763
|
+
4. on EnvManagerError: rethrow
|
|
764
|
+
5. on unexpected error: wrap as INTERNAL_ERROR with a redacted message
|
|
765
|
+
6. return the declared value
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
Approval helper:
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
async function requireApproval(exec, toolName: string, reason: string): Promise<void> {
|
|
772
|
+
if (!exec.agent) throw new EnvManagerError('SESSION_CONTEXT_UNAVAILABLE', '...')
|
|
773
|
+
const outcome = await ctx.approval.request({
|
|
774
|
+
agent: exec.agent,
|
|
775
|
+
toolName,
|
|
776
|
+
callId: exec.callId,
|
|
777
|
+
reason,
|
|
778
|
+
displayReason: { en: reason, zh: reasonZh },
|
|
779
|
+
signal: exec.signal,
|
|
780
|
+
})
|
|
781
|
+
if (outcome !== 'allowed-once') throw new EnvManagerError('POLICY_DENIED', '...')
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
The `displayReason` is localized; the `reason` is the stable English fallback.
|
|
786
|
+
|
|
787
|
+
## 12. HTTP transport
|
|
788
|
+
|
|
789
|
+
### 12.1 Route
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
ctx.webServer.register({ kind: 'prefix', path: '/env-manager/api', handler })
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
- `POST` only; other methods → 405 with `Allow: POST`.
|
|
796
|
+
- `Content-Type: application/json; charset=utf-8` required.
|
|
797
|
+
- Body limit 2 MiB; exceeding it → 413.
|
|
798
|
+
- Request header `x-env-manager-request: 1` required, otherwise 403.
|
|
799
|
+
- If an `Origin` header is present it must equal `ctx.webServer.getOrigin()`,
|
|
800
|
+
otherwise 403. No CORS headers are ever sent.
|
|
801
|
+
- The handler never reads cookies or credentials. A credential value may appear only in
|
|
802
|
+
the `credential` request body, is forwarded directly to the credential service and is
|
|
803
|
+
never logged or echoed.
|
|
804
|
+
|
|
805
|
+
### 12.2 Envelope
|
|
806
|
+
|
|
807
|
+
Request:
|
|
808
|
+
|
|
809
|
+
```json
|
|
810
|
+
{ "op": "record.save", "payload": { "kind": "environment", "yaml": "id: test-a\n..." } }
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Success `200`:
|
|
814
|
+
|
|
815
|
+
```json
|
|
816
|
+
{ "ok": true, "data": { ... } }
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
Failure:
|
|
820
|
+
|
|
821
|
+
```json
|
|
822
|
+
{ "ok": false, "error": { "code": "RECORD_INVALID", "message": "...", "details": { ... } } }
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Status map: `INVALID_ARGUMENT` 400, `POLICY_DENIED`/`FORBIDDEN` 403, `*_NOT_FOUND` 404,
|
|
826
|
+
`RECORD_IN_USE`/`BINDING_CONFLICT`/`DEPLOYMENT_AMBIGUOUS` 409, `RECORD_INVALID`/
|
|
827
|
+
`RULE_*`/`PARAMETER_*` 422, `CREDENTIAL_UNAVAILABLE` 503, anything else 500 (including
|
|
828
|
+
`CREDENTIAL_WRITE_FAILED`). The body always carries the code; the status is for tooling.
|
|
829
|
+
|
|
830
|
+
### 12.3 Operations
|
|
831
|
+
|
|
832
|
+
| op | payload | data |
|
|
833
|
+
|---|---|---|
|
|
834
|
+
| `meta` | — | dataDir, version, counts, warnings |
|
|
835
|
+
| `list` | `{ kind }` | summaries |
|
|
836
|
+
| `get` | `{ kind, id }` | `{ record, yaml }` |
|
|
837
|
+
| `find` | `{ query }` | hits |
|
|
838
|
+
| `save` | `{ kind, record?, yaml?, dryRun? }` | save result |
|
|
839
|
+
| `remove` | `{ kind, id, confirm?, dryRun? }` | remove result |
|
|
840
|
+
| `validate` | — | validation report |
|
|
841
|
+
| `resolve` | `{ environmentId, serviceId, deploymentId? }` | context |
|
|
842
|
+
| `route` | `{ operation, environmentId, serviceId, deploymentId? }` | resolution |
|
|
843
|
+
| `credential` | `{ ref, value }` | `{ ref, configured }` |
|
|
844
|
+
| `credentialUnset` | `{ ref }` | `{ ref, removed }` |
|
|
845
|
+
|
|
846
|
+
The UI cannot read or write session context; that is agent-only in V1 (§9).
|
|
847
|
+
|
|
848
|
+
### 12.4 Actor policy
|
|
849
|
+
|
|
850
|
+
`record.save` and `record.remove` run with actor `'ui'`:
|
|
851
|
+
|
|
852
|
+
- production environment save is denied unless `allowUiProductionWrites`;
|
|
853
|
+
- `record.remove` requires `confirm: true` unless `dryRun: true`;
|
|
854
|
+
- every write is logged with actor, op, kind, id and outcome (no record content).
|
|
855
|
+
|
|
856
|
+
`credential` and `credentialUnset` are UI-only, restricted to the `env-manager/`
|
|
857
|
+
namespace and never write to the registry. They log only the ref and outcome.
|
|
858
|
+
|
|
859
|
+
## 13. Client module
|
|
860
|
+
|
|
861
|
+
Structural contract:
|
|
862
|
+
|
|
863
|
+
```js
|
|
864
|
+
return {
|
|
865
|
+
inject: ['slots', 'locale'],
|
|
866
|
+
apply(ctx) {
|
|
867
|
+
ctx.effect(() => ctx.locale.register('envManager', { en, zh }))
|
|
868
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
|
|
869
|
+
{ name: 'sidebar.panellist', id: 'env-manager', order: 40 }, Icon))
|
|
870
|
+
ctx.slots.inject('main', () => ctx.slots.register(
|
|
871
|
+
{ name: 'main', key: 'env-manager' }, Panel))
|
|
872
|
+
},
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
- All data access goes through `createApi()` in `client.js`, which posts to
|
|
877
|
+
`/env-manager/api` with the required header and maps failures to a UI error object.
|
|
878
|
+
- The panel keeps state locally (`useState`/`useEffect`); no global store.
|
|
879
|
+
- Rendering and interactions are specified in [`ui-design.md`](ui-design.md).
|
|
880
|
+
|
|
881
|
+
## 14. Memory bridge
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
interface MemoryBridge {
|
|
885
|
+
readonly available: boolean
|
|
886
|
+
emit(record: OperationRecord): Promise<void>
|
|
887
|
+
observe(scope: MemoryScope): Promise<readonly Observation[]>
|
|
888
|
+
propose(candidate: CandidateChange): Promise<string> // returns proposal id
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
V1 ships `NullMemoryBridge`: `available: false`, `emit` resolves without writing,
|
|
893
|
+
`observe` returns `[]`, `propose` throws `MEMORY_BRIDGE_UNAVAILABLE`. It is selected
|
|
894
|
+
when no memory provider is registered; a future adapter plugs in without changing the
|
|
895
|
+
tools because nothing in V1 calls `emit` (no execution).
|
|
896
|
+
|
|
897
|
+
## 15. Cross-cutting
|
|
898
|
+
|
|
899
|
+
### 15.1 Logging
|
|
900
|
+
|
|
901
|
+
`src/logger.ts` wraps `ctx.get('logger')` when it is callable, else `console`.
|
|
902
|
+
Log levels: `info` for lifecycle (dataDir, loaded counts), `warn` for skipped records,
|
|
903
|
+
`error` for failed loads. Records and credential refs are logged as ids only; never a
|
|
904
|
+
whole record, never a value.
|
|
905
|
+
|
|
906
|
+
### 15.2 Redaction
|
|
907
|
+
|
|
908
|
+
`redactValue(text)` removes anything that looks like a URL userinfo, a bearer token,
|
|
909
|
+
or a private key block from messages before they reach the model or the UI. Tool
|
|
910
|
+
results never include `credentialRef` values, only configured state.
|
|
911
|
+
|
|
912
|
+
### 15.3 Cancellation
|
|
913
|
+
|
|
914
|
+
`execute` receives `exec.signal`; long loops (registry load, resolve) check it between
|
|
915
|
+
files and between fields. A cancelled call surfaces DSH's own abort error unchanged.
|
|
916
|
+
|
|
917
|
+
### 15.4 Performance budgets
|
|
918
|
+
|
|
919
|
+
| Operation | Budget |
|
|
920
|
+
|---|---|
|
|
921
|
+
| registry load (500 records) | 100 ms |
|
|
922
|
+
| resolve (50 fields) | 5 ms |
|
|
923
|
+
| route with skill snapshot | 50 ms |
|
|
924
|
+
| HTTP round trip (local) | 150 ms |
|
|
925
|
+
|
|
926
|
+
## 16. Test obligations
|
|
927
|
+
|
|
928
|
+
| Module | Tests | From AGENTS §13 |
|
|
929
|
+
|---|---|---|
|
|
930
|
+
| `storage` | atomic write, missing file, invalid YAML, schema mismatch, backups, traversal rejection | required |
|
|
931
|
+
| `registry` | load/lookup, duplicate id, broken references, alias collision | required |
|
|
932
|
+
| `resolver` | precedence, field merge, provenance, undefined/cycle/traversal, runtime selection | required |
|
|
933
|
+
| `router` | each level, conflict, missing skill, source/provider mismatch, policy, parameters | required |
|
|
934
|
+
| `session` | set/get/clear, isolation, corrupt fallback | required |
|
|
935
|
+
| `tools` | output matches schema, error codes, no credential values, approval denial | required |
|
|
936
|
+
| `app` | save→get round trip, delete in use, validate report | new |
|
|
937
|
+
| `http` | auth header, origin, method, size cap, status map, production denial | new |
|
|
938
|
+
| `client` | manual checklist in `ui-design.md` §9 | manual |
|
|
939
|
+
|
|
940
|
+
Tests live in `tests/<module>/*.test.ts`, fixtures in `tests/fixtures/<scenario>/`.
|
|
941
|
+
|
|
942
|
+
## 17. Traceability
|
|
943
|
+
|
|
944
|
+
| Design section | Implements | Acceptance |
|
|
945
|
+
|---|---|---|
|
|
946
|
+
| §4 storage | ADR-0002 | AC-01, AC-02 |
|
|
947
|
+
| §7 resolver | ADR-0005 | AC-03, AC-04 |
|
|
948
|
+
| §8 router | ADR-0003 | AC-05…AC-07 |
|
|
949
|
+
| §9 session | ADR-0007 | AC-08 |
|
|
950
|
+
| §12 HTTP | ADR-0009 | UI manual QA (`ui-design.md` §12) |
|
|
951
|
+
| §11 tools | tool-contract.md | AC-02…AC-08 |
|
|
952
|
+
|
|
953
|
+
## 18. Deliberate omissions
|
|
954
|
+
|
|
955
|
+
- No cache beyond one in-flight load.
|
|
956
|
+
- No cross-process locking.
|
|
957
|
+
- No Skill execution.
|
|
958
|
+
- No session-event projection.
|
|
959
|
+
- No digest pinning.
|
|
960
|
+
- No plugin-side bundler.
|
|
961
|
+
- No database.
|