dsh-tower 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +78 -5
- package/PRINCIPLES.md +163 -0
- package/README.en.md +35 -19
- package/README.md +28 -18
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/cordis.patch.yml +56 -28
- package/docs/README.md +7 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +70 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +52 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +192 -43
- package/docs/guide/hot-reload.md +5 -5
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/operations/security.md +1 -1
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -9
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +53 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +157 -0
- package/docs/reference/tools.generated.md +8 -5
- package/docs/reference/tools.md +10 -7
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +4 -4
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +10 -8
- package/domain/batch.js +2 -2
- package/domain/components.js +641 -0
- package/domain/config/schema.js +62 -28
- package/domain/contracts/outputs.js +16 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +308 -43
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +12 -1
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +347 -0
- package/host/capability-batch.js +15 -0
- package/host/capability-hmr.js +30 -0
- package/host/capability-import.js +11 -0
- package/host/capability-leases.js +11 -0
- package/host/capability-registry.js +149 -0
- package/host/capability-tests.js +16 -0
- package/{lib → host}/doctor.js +157 -18
- package/{lib → host}/hmr.js +2 -4
- package/{lib → host}/modeStore.js +1 -1
- package/host/presets.js +77 -0
- package/{lib → host}/tool-registry.js +235 -44
- package/host/tower.js +32 -0
- package/index.js +40 -25
- package/locale/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +24 -4
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +18 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/lib/tool-surface.mjs +85 -0
- package/scripts/mount-smoke/fixtures.mjs +3 -3
- package/scripts/mount-smoke/surface.mjs +96 -27
- package/scripts/mount-smoke.mjs +3 -1
- package/scripts/pack-manifest.txt +75 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +52 -0
- package/scripts/surface-baseline.mjs +118 -0
- package/scripts/types-coverage.mjs +184 -0
- package/scripts/types-strict-manifest.txt +10 -0
- package/scripts/types-strict.mjs +141 -0
- package/skills.js +8 -1
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +9 -8
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +36 -40
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +11 -4
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +29 -13
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/presets.js +0 -35
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
package/domain/config/schema.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// This module is pure: it declares the Schemastery schema, validates raw config
|
|
4
4
|
// objects, and materializes defaults. Profile and repository loading (fs and the
|
|
5
|
-
// process-local profile snapshot) live in
|
|
5
|
+
// process-local profile snapshot) live in host/config.js, which re-exports
|
|
6
6
|
// `Config` and `resolveTowerConfig` for compatibility.
|
|
7
7
|
//
|
|
8
8
|
// Layers, lowest precedence first:
|
|
@@ -33,7 +33,10 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
36
|
-
|
|
36
|
+
// #121: the `tools` group is gone. Every capability that used to be a
|
|
37
|
+
// `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
|
|
38
|
+
// component row is the switch. What remains here is genuine tuning policy.
|
|
39
|
+
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
|
|
37
40
|
|
|
38
41
|
const SECTION_KEYS = {
|
|
39
42
|
init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
|
|
@@ -75,9 +78,8 @@ const SECTION_KEYS = {
|
|
|
75
78
|
gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
|
|
76
79
|
docs: ['board', 'boardLocation'],
|
|
77
80
|
archive: ['enabled', 'afterDays', 'maxMissions'],
|
|
78
|
-
tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
|
|
79
81
|
leases: ['ttlMs'],
|
|
80
|
-
dev: ['
|
|
82
|
+
dev: ['reloadDelayMs', 'reloadRequiredIds'],
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
|
|
@@ -261,43 +263,62 @@ export const Config = Schema.object({
|
|
|
261
263
|
})
|
|
262
264
|
.default({})
|
|
263
265
|
.description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
|
|
264
|
-
tools: Schema.object({
|
|
265
|
-
exposeOperational: Schema.boolean()
|
|
266
|
-
.default(true)
|
|
267
|
-
.description('Master switch for batch/resource/import/legacy/recover/test/doctor tools.'),
|
|
268
|
-
exposeBatch: Schema.boolean().default(true).description('Register tower_batch.'),
|
|
269
|
-
exposeImport: Schema.boolean().default(true).description('Register tower_import_legacy/tower_legacy_map.'),
|
|
270
|
-
exposeLeases: Schema.boolean().default(true).description('Register tower_resource advisory leases.'),
|
|
271
|
-
exposeTests: Schema.boolean().default(true).description('Register tower_test; gates.requireTests requires it.'),
|
|
272
|
-
exposeRecovery: Schema.boolean().default(true).description('Register tower_recover (stale state-lock escape).'),
|
|
273
|
-
exposeDoctor: Schema.boolean().default(true).description('Register tower_doctor (read-only health contract).'),
|
|
274
|
-
})
|
|
275
|
-
.default({})
|
|
276
|
-
.description('Tool-surface policy.'),
|
|
277
266
|
leases: Schema.object({
|
|
278
267
|
ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
|
|
279
268
|
})
|
|
280
269
|
.default({})
|
|
281
270
|
.description('Advisory resource leases.'),
|
|
282
271
|
dev: Schema.object({
|
|
283
|
-
hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
|
|
284
272
|
reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
|
|
285
273
|
reloadRequiredIds: Schema.array(Schema.string())
|
|
286
|
-
.default(['tower
|
|
287
|
-
.description('Loader entry ids passed to reconcileProfile as required.'),
|
|
274
|
+
.default(['tower', 'tower-host', 'preset-tower-worker'])
|
|
275
|
+
.description('Loader entry ids passed to reconcileProfile as required. Optional component rows are deliberately not required, so a row switched off is not a reload failure.'),
|
|
288
276
|
})
|
|
289
277
|
.default({})
|
|
290
278
|
.description('Development and hot-reload behavior.'),
|
|
291
279
|
}).description('DSH Tower configuration')
|
|
292
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The user-facing config surface (#84): every leaf key one level deep plus the
|
|
283
|
+
* names of the object sections. The generated config reference, `tower_doctor`
|
|
284
|
+
* and the surface baseline all count this same shape, so "how many concepts"
|
|
285
|
+
* is one number rather than three near-identical ones.
|
|
286
|
+
*
|
|
287
|
+
* @param {any} [config] the Schemastery root (defaults to the live `Config`)
|
|
288
|
+
* @returns {{ keys: string[], sections: string[], count: number }}
|
|
289
|
+
*/
|
|
290
|
+
export function configSurface(config = Config) {
|
|
291
|
+
const keys = []
|
|
292
|
+
const sections = []
|
|
293
|
+
for (const [key, schema] of Object.entries(config?.dict ?? {})) {
|
|
294
|
+
const dict = schema?.dict
|
|
295
|
+
if (dict && typeof dict === 'object') {
|
|
296
|
+
sections.push(key)
|
|
297
|
+
for (const child of Object.keys(dict)) keys.push(`${key}.${child}`)
|
|
298
|
+
} else {
|
|
299
|
+
keys.push(key)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { keys, sections, count: keys.length }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @param {any} value */
|
|
293
306
|
export function clone(value) {
|
|
294
307
|
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
295
308
|
}
|
|
296
309
|
|
|
310
|
+
/**
|
|
311
|
+
* @param {any} value
|
|
312
|
+
* @returns {boolean}
|
|
313
|
+
*/
|
|
297
314
|
export function isPlainObject(value) {
|
|
298
315
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
299
316
|
}
|
|
300
317
|
|
|
318
|
+
/**
|
|
319
|
+
* @param {any} base
|
|
320
|
+
* @param {any} override
|
|
321
|
+
*/
|
|
301
322
|
export function deepMerge(base, override) {
|
|
302
323
|
if (override === undefined) return clone(base) ?? {}
|
|
303
324
|
if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
|
|
@@ -326,6 +347,10 @@ export function normalizeLegacy(raw = {}) {
|
|
|
326
347
|
return out
|
|
327
348
|
}
|
|
328
349
|
|
|
350
|
+
/**
|
|
351
|
+
* @param {any} value
|
|
352
|
+
* @param {string} label
|
|
353
|
+
*/
|
|
329
354
|
function assertModelKeys(value, label) {
|
|
330
355
|
const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
|
|
331
356
|
if (bad.length > 0) {
|
|
@@ -333,12 +358,16 @@ function assertModelKeys(value, label) {
|
|
|
333
358
|
}
|
|
334
359
|
}
|
|
335
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @param {any} raw
|
|
363
|
+
* @param {string} label
|
|
364
|
+
*/
|
|
336
365
|
export function assertKnownKeys(raw, label) {
|
|
337
366
|
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
338
367
|
if (unknown.length > 0) {
|
|
339
368
|
throw new TowerToolError(
|
|
340
369
|
'config-unknown-key',
|
|
341
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev
|
|
370
|
+
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev? }`,
|
|
342
371
|
)
|
|
343
372
|
}
|
|
344
373
|
for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
|
|
@@ -363,7 +392,11 @@ export function assertKnownKeys(raw, label) {
|
|
|
363
392
|
|
|
364
393
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
365
394
|
|
|
366
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* Reject prototype-pollution style keys anywhere in a repository config.
|
|
397
|
+
* @param {any} value
|
|
398
|
+
* @param {string} label
|
|
399
|
+
*/
|
|
367
400
|
export function assertSafeKeys(value, label) {
|
|
368
401
|
if (Array.isArray(value)) {
|
|
369
402
|
for (const item of value) assertSafeKeys(item, label)
|
|
@@ -378,6 +411,7 @@ export function assertSafeKeys(value, label) {
|
|
|
378
411
|
}
|
|
379
412
|
}
|
|
380
413
|
|
|
414
|
+
/** @param {any} [rawConfig] */
|
|
381
415
|
export function resolveTowerConfig(rawConfig) {
|
|
382
416
|
const raw = rawConfig ?? {}
|
|
383
417
|
if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
|
|
@@ -389,11 +423,12 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
389
423
|
throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
|
|
390
424
|
}
|
|
391
425
|
const mode = clone(parsed.mode ?? {})
|
|
392
|
-
if (parsed.gates?.requireTests === true) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
426
|
+
if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
|
|
427
|
+
// #121: the tower-tests *component row* is the capability switch now; a
|
|
428
|
+
// repository that turns the row off cannot satisfy gates.requireTests. The
|
|
429
|
+
// component-level half of this invariant is enforced at plan/mode entry,
|
|
430
|
+
// where the mounted rows are known (host/presets.js).
|
|
431
|
+
throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
|
|
397
432
|
}
|
|
398
433
|
// Canonical keys win; the legacy top-level aliases only fill gaps.
|
|
399
434
|
const rawMode = raw.mode ?? {}
|
|
@@ -420,7 +455,6 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
420
455
|
gates: clone(parsed.gates ?? {}),
|
|
421
456
|
docs: clone(parsed.docs ?? {}),
|
|
422
457
|
archive: clone(parsed.archive ?? {}),
|
|
423
|
-
tools: clone(parsed.tools ?? {}),
|
|
424
458
|
leases: clone(parsed.leases ?? {}),
|
|
425
459
|
dev: clone(parsed.dev ?? {}),
|
|
426
460
|
}
|
|
@@ -4,7 +4,12 @@ const S = {
|
|
|
4
4
|
str: { type: 'string' },
|
|
5
5
|
int: { type: 'integer' },
|
|
6
6
|
bool: { type: 'boolean' },
|
|
7
|
+
/** @param {any} items */
|
|
7
8
|
arr: (items) => ({ type: 'array', items }),
|
|
9
|
+
/**
|
|
10
|
+
* @param {Record<string, any>} [properties]
|
|
11
|
+
* @param {string[]} [required]
|
|
12
|
+
*/
|
|
8
13
|
obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
|
|
9
14
|
any: {},
|
|
10
15
|
}
|
|
@@ -79,6 +84,7 @@ const SCHEMAS = {
|
|
|
79
84
|
recentFindings: S.arr(S.obj()),
|
|
80
85
|
missions: S.arr(S.obj()),
|
|
81
86
|
activity: S.arr(S.obj()),
|
|
87
|
+
gitLock: S.obj({ held: S.bool, pid: S.any, phase: S.any, heldMs: S.any }),
|
|
82
88
|
mode: S.any,
|
|
83
89
|
spawn: S.any,
|
|
84
90
|
towerAgentId: S.any,
|
|
@@ -169,7 +175,7 @@ const SCHEMAS = {
|
|
|
169
175
|
['counts', 'total'],
|
|
170
176
|
),
|
|
171
177
|
review: S.obj(
|
|
172
|
-
{ review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
|
|
178
|
+
{ review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, appended: S.bool, reviewerRounds: S.int },
|
|
173
179
|
['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
|
|
174
180
|
),
|
|
175
181
|
preflight: S.obj(
|
|
@@ -243,6 +249,8 @@ const SCHEMAS = {
|
|
|
243
249
|
{
|
|
244
250
|
ok: S.bool,
|
|
245
251
|
checks: S.arr(S.obj()),
|
|
252
|
+
stateLock: S.any,
|
|
253
|
+
gitLock: S.any,
|
|
246
254
|
stateSchema: S.obj({
|
|
247
255
|
currentVersion: S.any,
|
|
248
256
|
supportedVersion: S.int,
|
|
@@ -250,6 +258,7 @@ const SCHEMAS = {
|
|
|
250
258
|
status: S.str,
|
|
251
259
|
}),
|
|
252
260
|
reports: S.any,
|
|
261
|
+
surface: S.any,
|
|
253
262
|
archive: S.obj({
|
|
254
263
|
missions: S.any,
|
|
255
264
|
archivedMissions: S.int,
|
|
@@ -266,7 +275,7 @@ const SCHEMAS = {
|
|
|
266
275
|
['ok', 'checks'],
|
|
267
276
|
),
|
|
268
277
|
recover: S.obj(
|
|
269
|
-
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
|
|
278
|
+
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, forced: S.bool, error: S.str },
|
|
270
279
|
['ok', 'action'],
|
|
271
280
|
),
|
|
272
281
|
test: S.obj(
|
|
@@ -294,10 +303,15 @@ const SCHEMAS = {
|
|
|
294
303
|
legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
|
|
295
304
|
}
|
|
296
305
|
|
|
306
|
+
/**
|
|
307
|
+
* @param {any} _args
|
|
308
|
+
* @param {any} value
|
|
309
|
+
*/
|
|
297
310
|
function renderValue(_args, value) {
|
|
298
311
|
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
299
312
|
}
|
|
300
313
|
|
|
314
|
+
/** @param {any} schema */
|
|
301
315
|
function output(schema) {
|
|
302
316
|
return { schema, render: renderValue }
|
|
303
317
|
}
|
|
@@ -25,6 +25,7 @@ export const RecoverInput = Type.Object({
|
|
|
25
25
|
repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
|
|
26
26
|
action: enumOf(RECOVERY_ACTIONS, { description: 'Recovery action.' }),
|
|
27
27
|
confirm: Type.Optional(Type.String({ description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' })),
|
|
28
|
+
force: Type.Optional(Type.Boolean({ description: 'clear-stale-lock: clear the lock even when its recorded PID is alive (a recycled PID); requires confirm and still moves the record to a .recovered-<ts> backup.' })),
|
|
28
29
|
}, { additionalProperties: false })
|
|
29
30
|
|
|
30
31
|
export const TestInput = Type.Object({
|
package/domain/errors.js
CHANGED
|
@@ -3,11 +3,33 @@
|
|
|
3
3
|
// Everything a Tower tool throws reaches the model as
|
|
4
4
|
// [tower:<code>] <original message>
|
|
5
5
|
// so callers (and tests) can branch on the code instead of matching prose.
|
|
6
|
+
// The catalog also carries the recovery metadata the model can act on:
|
|
7
|
+
//
|
|
8
|
+
// - `retryable` — the same input may succeed later without a change;
|
|
9
|
+
// - `nextAction` — the concrete recovery step, rendered once as a trailing
|
|
10
|
+
// ` — next: …` segment (plus ` (retryable)` when retryable);
|
|
11
|
+
// - `trigger` — the typical condition, documented in errors.md.
|
|
12
|
+
//
|
|
13
|
+
// Why the message suffix rather than a structured error payload: the DSH tool
|
|
14
|
+
// contract declares output schemas for successful values only, and a thrown
|
|
15
|
+
// error reaches the model as its rendered message. There is no separate
|
|
16
|
+
// structured error channel to return `{ code, retryable, nextAction }` through,
|
|
17
|
+
// so the suffix is the model-visible channel; the error object still carries
|
|
18
|
+
// `code`, `retryable`, `nextAction` and `details` for programmatic callers.
|
|
19
|
+
// Codes without a recovery step render byte-identically to the pre-#74 form.
|
|
20
|
+
//
|
|
6
21
|
// The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
|
|
7
|
-
//
|
|
22
|
+
// and scripts/generate-error-reference.mjs keep the two in sync.
|
|
23
|
+
// @ts-check
|
|
8
24
|
|
|
9
25
|
/** Neutral error type shared by the git adapter and the domain classifier. */
|
|
10
26
|
export class GitError extends Error {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {string} detail
|
|
30
|
+
* @param {number | undefined} code the child-process errno, not a Tower code
|
|
31
|
+
* @param {{ timedOut?: boolean, bufferOverflow?: boolean, stdout?: string }} [meta]
|
|
32
|
+
*/
|
|
11
33
|
constructor(args, detail, code, meta = {}) {
|
|
12
34
|
super(`git ${args.join(' ')} failed: ${detail}`)
|
|
13
35
|
this.name = 'GitError'
|
|
@@ -18,74 +40,317 @@ export class GitError extends Error {
|
|
|
18
40
|
}
|
|
19
41
|
}
|
|
20
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} ErrorCodeEntry
|
|
45
|
+
* @property {string} description
|
|
46
|
+
* @property {string} trigger
|
|
47
|
+
* @property {boolean} retryable
|
|
48
|
+
* @property {string | null} nextAction
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One stable entry per failure family. `description` and `trigger` are the
|
|
53
|
+
* documented meaning; `retryable`/`nextAction` are the recovery contract the
|
|
54
|
+
* tool boundary renders.
|
|
55
|
+
* @type {Record<string, ErrorCodeEntry>}
|
|
56
|
+
*/
|
|
21
57
|
export const ERROR_CODES = {
|
|
22
|
-
'invalid-argument':
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
'invalid-argument': {
|
|
59
|
+
description: 'A tool argument is missing, malformed, or not allowed.',
|
|
60
|
+
trigger: '`missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update.',
|
|
61
|
+
retryable: false,
|
|
62
|
+
nextAction: null,
|
|
63
|
+
},
|
|
64
|
+
'repo-invalid': {
|
|
65
|
+
description: 'repoRoot is missing, relative, or not inside a Git repository.',
|
|
66
|
+
trigger: 'passing a path in a directory that is not a repo',
|
|
67
|
+
retryable: false,
|
|
68
|
+
nextAction: 'pass an absolute path to a directory inside a Git repository',
|
|
69
|
+
},
|
|
70
|
+
'not-initialized': {
|
|
71
|
+
description: 'Tower is not initialized for this repository; run tower_init.',
|
|
72
|
+
trigger: 'a read tool before `tower_init`',
|
|
73
|
+
retryable: false,
|
|
74
|
+
nextAction: 'run tower_init for this repository',
|
|
75
|
+
},
|
|
76
|
+
'mission-not-found': {
|
|
77
|
+
description: 'No mission with that id exists.',
|
|
78
|
+
trigger: 'a stale or mistyped `M9`',
|
|
79
|
+
retryable: false,
|
|
80
|
+
nextAction: 'list missions with tower_status',
|
|
81
|
+
},
|
|
82
|
+
'finding-not-found': {
|
|
83
|
+
description: 'No finding with that id exists.',
|
|
84
|
+
trigger: '`tower_findings({id:"F99"})`',
|
|
85
|
+
retryable: false,
|
|
86
|
+
nextAction: 'list findings with tower_findings',
|
|
87
|
+
},
|
|
88
|
+
'invalid-transition': {
|
|
89
|
+
description: 'The requested state transition is not allowed.',
|
|
90
|
+
trigger: 'a blocked→merged attempt; a finding transition not in the graph; a reviewer mutating findings; completing a mission with open tasks, blockers, no commits, or unread inbox messages.',
|
|
91
|
+
retryable: false,
|
|
92
|
+
nextAction: null,
|
|
93
|
+
},
|
|
94
|
+
'stale-base': {
|
|
95
|
+
description: 'The repository state changed while the operation was being prepared; retry.',
|
|
96
|
+
trigger: 'two-phase mutation revalidation',
|
|
97
|
+
retryable: true,
|
|
98
|
+
nextAction: 'retry the tool; if it keeps failing, run tower_preflight and inspect tower_status',
|
|
99
|
+
},
|
|
100
|
+
'state-locked': {
|
|
101
|
+
description: 'Another Tower writer holds the state lock.',
|
|
102
|
+
trigger: 'concurrent mutation after the lock timeout',
|
|
103
|
+
retryable: true,
|
|
104
|
+
nextAction: 'wait for the other Tower writer to finish, then retry',
|
|
105
|
+
},
|
|
106
|
+
'state-corrupt': {
|
|
107
|
+
description: 'The Tower state file is unreadable or invalid.',
|
|
108
|
+
trigger: 'hand-edited `state.json`; invalid JSON; no usable `schemaVersion`',
|
|
109
|
+
retryable: false,
|
|
110
|
+
nextAction: 'restore state.json from a backup, or re-initialize after filing the corruption',
|
|
111
|
+
},
|
|
112
|
+
'state-version-unsupported': {
|
|
113
|
+
description: 'The Tower state file uses a schema version this build cannot read or downgrade.',
|
|
114
|
+
trigger: 'opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade',
|
|
115
|
+
retryable: false,
|
|
116
|
+
nextAction: 'upgrade dsh-tower to a release that supports this state schemaVersion',
|
|
117
|
+
},
|
|
118
|
+
'state-migration-failed': {
|
|
119
|
+
description: 'A Tower state migration failed; the original state file was not modified.',
|
|
120
|
+
trigger: 'a bug in a migration step; see the pre-migration `state.json.v<N>.bak`',
|
|
121
|
+
retryable: false,
|
|
122
|
+
nextAction: 'restore the pre-migration backup state.json.v<N>.bak and report the migration bug',
|
|
123
|
+
},
|
|
124
|
+
'config-invalid': {
|
|
125
|
+
description: 'Tower configuration is invalid.',
|
|
126
|
+
trigger: 'malformed repo `config.json`, a value that violates the schema',
|
|
127
|
+
retryable: false,
|
|
128
|
+
nextAction: 'fix the repository config file, then retry',
|
|
129
|
+
},
|
|
130
|
+
'config-unknown-key': {
|
|
131
|
+
description: 'Tower configuration has an unknown top-level key.',
|
|
132
|
+
trigger: 'a typo such as `allowAgentToggle`',
|
|
133
|
+
retryable: false,
|
|
134
|
+
nextAction: 'remove the unknown key from the repository config file, then retry',
|
|
135
|
+
},
|
|
136
|
+
'git-failed': {
|
|
137
|
+
description: 'A git command failed.',
|
|
138
|
+
trigger: 'a rejected merge command, invalid ref',
|
|
139
|
+
retryable: false,
|
|
140
|
+
nextAction: null,
|
|
141
|
+
},
|
|
142
|
+
'git-timeout': {
|
|
143
|
+
description: 'A git command timed out or exceeded its output buffer.',
|
|
144
|
+
trigger: 'slow pre-merge hook, huge diff',
|
|
145
|
+
retryable: true,
|
|
146
|
+
nextAction: 'retry; if it persists, raise git.timeoutMs or merge.timeoutMs',
|
|
147
|
+
},
|
|
148
|
+
'worktree-dirty': {
|
|
149
|
+
description: 'The mission worktree has uncommitted changes and cannot complete.',
|
|
150
|
+
trigger: 'staged, modified, or untracked files left behind before `tower_complete`',
|
|
151
|
+
retryable: false,
|
|
152
|
+
nextAction: 'commit or remove the worktree changes, then retry tower_complete',
|
|
153
|
+
},
|
|
154
|
+
'spawn-unavailable': {
|
|
155
|
+
description: 'The subagents service is unavailable.',
|
|
156
|
+
trigger: 'a Host without `subagents`',
|
|
157
|
+
retryable: false,
|
|
158
|
+
nextAction: 'enable the DSH subagents service, then retry',
|
|
159
|
+
},
|
|
160
|
+
'provider-missing': {
|
|
161
|
+
description: 'No subagent provider is registered.',
|
|
162
|
+
trigger: 'an empty provider list',
|
|
163
|
+
retryable: false,
|
|
164
|
+
nextAction: 'register a subagent provider in the DSH profile, then retry',
|
|
165
|
+
},
|
|
166
|
+
'provider-cooldown': {
|
|
167
|
+
description: 'The provider is throttled; spawn is in a short cooldown.',
|
|
168
|
+
trigger: 'repeated retryable provider failures; wait for `blockedUntil`',
|
|
169
|
+
retryable: true,
|
|
170
|
+
nextAction: 'wait for the cooldown to expire, then retry',
|
|
171
|
+
},
|
|
172
|
+
'preset-missing': {
|
|
173
|
+
description: 'The configured agent preset is not registered.',
|
|
174
|
+
trigger: 'missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted',
|
|
175
|
+
retryable: false,
|
|
176
|
+
nextAction: 'install the tower-worker and tower-reviewer agent presets and restart DSH',
|
|
177
|
+
},
|
|
178
|
+
'reviewer-prerequisite': {
|
|
179
|
+
description: 'The reviewer spawn prerequisite is not met.',
|
|
180
|
+
trigger: 'reviewer spawned before the mission is `completed`, or before a worktree exists',
|
|
181
|
+
retryable: false,
|
|
182
|
+
nextAction: 'complete the mission first, then spawn the reviewer',
|
|
183
|
+
},
|
|
184
|
+
'not-authorized': {
|
|
185
|
+
description: 'The calling agent role may not perform this action.',
|
|
186
|
+
trigger: 'a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings',
|
|
187
|
+
retryable: false,
|
|
188
|
+
nextAction: 'call the tool as the tower, or as the worker/reviewer it is assigned to',
|
|
189
|
+
},
|
|
190
|
+
'workspace-busy': {
|
|
191
|
+
description: 'Another Tower session owns this repository workspace lock.',
|
|
192
|
+
trigger: '`mode.workspaceLock:true` and a second session calls a repo-scoped tool',
|
|
193
|
+
retryable: true,
|
|
194
|
+
nextAction: 'run /tower off in the other session or wait for its lock to become stale',
|
|
195
|
+
},
|
|
196
|
+
'worker-limit': {
|
|
197
|
+
description: 'The repository reached spawn.maxLiveWorkers live workers.',
|
|
198
|
+
trigger: 'spawning a fifth worker with the default limit',
|
|
199
|
+
retryable: false,
|
|
200
|
+
nextAction: 'wait for a live worker to finish or raise spawn.maxLiveWorkers',
|
|
201
|
+
},
|
|
202
|
+
'rate-limited': {
|
|
203
|
+
description: 'The tower_send per-minute rate limit was reached.',
|
|
204
|
+
trigger: '`send.maxPerMinute` exceeded',
|
|
205
|
+
retryable: true,
|
|
206
|
+
nextAction: 'wait for the next per-minute window or raise send.maxPerMinute',
|
|
207
|
+
},
|
|
208
|
+
'takeover-refused': {
|
|
209
|
+
description: 'The mission already has a live worker that was not stopped first.',
|
|
210
|
+
trigger: '`tower_spawn({takeover:true})` before `tower_interrupt`',
|
|
211
|
+
retryable: false,
|
|
212
|
+
nextAction: 'run tower_interrupt for the live worker, then retry the takeover',
|
|
213
|
+
},
|
|
214
|
+
'reload-unavailable': {
|
|
215
|
+
description: 'The DSH HMR service is not available in this profile.',
|
|
216
|
+
trigger: '`tower_reload` / `/tower reload` on a profile without HMR',
|
|
217
|
+
retryable: false,
|
|
218
|
+
nextAction: 'restart DSH instead of reloading',
|
|
219
|
+
},
|
|
220
|
+
'reload-failed': {
|
|
221
|
+
description: 'The HMR reload failed; check the DSH log and restart if needed.',
|
|
222
|
+
trigger: '`reconcileProfile` threw',
|
|
223
|
+
retryable: false,
|
|
224
|
+
nextAction: 'check the DSH log and restart DSH',
|
|
225
|
+
},
|
|
226
|
+
'tests-failed': {
|
|
227
|
+
description: 'Declared test discovery failed or did not meet its admission requirements.',
|
|
228
|
+
trigger: 'nonzero command, malformed output, minCount/required shortfall',
|
|
229
|
+
retryable: false,
|
|
230
|
+
nextAction: 'fix the failing tests, then retry tower_complete',
|
|
231
|
+
},
|
|
232
|
+
'tests-timeout': {
|
|
233
|
+
description: 'Test discovery exceeded its time or output budget.',
|
|
234
|
+
trigger: '`tests.timeoutMs`, or stdout over `tests.maxBytes`',
|
|
235
|
+
retryable: false,
|
|
236
|
+
nextAction: 'raise tests.timeoutMs or tests.maxBytes (or speed up the suite), then retry',
|
|
237
|
+
},
|
|
238
|
+
'lease-busy': {
|
|
239
|
+
description: 'Another holder owns the requested advisory lease.',
|
|
240
|
+
trigger: '`tower_resource acquire` while `heavy-ci` is held',
|
|
241
|
+
retryable: false,
|
|
242
|
+
nextAction: 'release the lease or wait for it to expire, then retry',
|
|
243
|
+
},
|
|
244
|
+
'lease-token': {
|
|
245
|
+
description: 'The lease release token does not match the current holder.',
|
|
246
|
+
trigger: 'releasing with another session\'s token',
|
|
247
|
+
retryable: false,
|
|
248
|
+
nextAction: 'use the token returned when the lease was acquired',
|
|
249
|
+
},
|
|
250
|
+
'batch-invalid': {
|
|
251
|
+
description: 'The batch request or its close evidence is invalid.',
|
|
252
|
+
trigger: 'unknown member, duplicate id, unsafe receipt path, missing receipt hash',
|
|
253
|
+
retryable: false,
|
|
254
|
+
nextAction: 'fix the batch request or the receipt hashes, then retry',
|
|
255
|
+
},
|
|
256
|
+
'legacy-invalid': {
|
|
257
|
+
description: 'The legacy snapshot or mapping is invalid.',
|
|
258
|
+
trigger: 'unknown schema, source inside the Tower state dir, mapping rewrite',
|
|
259
|
+
retryable: false,
|
|
260
|
+
nextAction: 'check the legacy snapshot path, schema and mapping, then retry',
|
|
261
|
+
},
|
|
262
|
+
'tool-internal': {
|
|
263
|
+
description: 'An unexpected failure; read the message and the activity log.',
|
|
264
|
+
trigger: 'a bug; read the message and the activity log',
|
|
265
|
+
retryable: false,
|
|
266
|
+
nextAction: null,
|
|
267
|
+
},
|
|
57
268
|
}
|
|
58
269
|
|
|
59
270
|
export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
|
|
271
|
+
|
|
272
|
+
/** @param {unknown} value */
|
|
60
273
|
export function isErrorCode(value) {
|
|
61
274
|
return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
|
|
62
275
|
}
|
|
63
276
|
|
|
64
|
-
|
|
277
|
+
/** @param {string} code */
|
|
278
|
+
export function errorEntry(code) {
|
|
279
|
+
return isErrorCode(code) ? ERROR_CODES[code] : null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} code */
|
|
283
|
+
export function errorDescription(code) {
|
|
284
|
+
return errorEntry(code)?.description ?? ''
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {string} code */
|
|
288
|
+
export function errorRetryable(code) {
|
|
289
|
+
return errorEntry(code)?.retryable === true
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @param {string} code */
|
|
293
|
+
export function errorNextAction(code) {
|
|
294
|
+
return errorEntry(code)?.nextAction ?? null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The model-visible message. Exactly one trailing ` — next: …` segment is
|
|
299
|
+
* appended when there is a recovery step; a retryable code marks the same
|
|
300
|
+
* segment with ` (retryable)`. A message that already carries the action is
|
|
301
|
+
* never suffixed twice.
|
|
302
|
+
* @param {string} code
|
|
303
|
+
* @param {string} message
|
|
304
|
+
* @param {{ nextAction?: string | null, retryable?: boolean }} [options]
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
export function renderErrorMessage(code, message, options = {}) {
|
|
308
|
+
const text = String(message)
|
|
309
|
+
const base = `[tower:${code}] ${text}`
|
|
310
|
+
const nextAction = options.nextAction ?? errorNextAction(code)
|
|
311
|
+
if (nextAction === null || nextAction === undefined) return base
|
|
312
|
+
if (text.includes(nextAction) || text.includes('— next:')) return base
|
|
313
|
+
const retryable = options.retryable ?? errorRetryable(code)
|
|
314
|
+
return `${base} — next: ${nextAction}${retryable ? ' (retryable)' : ''}`
|
|
315
|
+
}
|
|
65
316
|
|
|
66
317
|
export class TowerToolError extends Error {
|
|
318
|
+
/**
|
|
319
|
+
* @param {string} code
|
|
320
|
+
* @param {string} message
|
|
321
|
+
* @param {{ cause?: unknown, retryable?: boolean, nextAction?: string | null, details?: unknown }} [options]
|
|
322
|
+
*/
|
|
67
323
|
constructor(code, message, options = {}) {
|
|
68
|
-
|
|
324
|
+
const entry = errorEntry(code)
|
|
325
|
+
const retryable = options.retryable ?? entry?.retryable === true
|
|
326
|
+
const nextAction = options.nextAction ?? entry?.nextAction ?? null
|
|
327
|
+
super(renderErrorMessage(code, message, { nextAction, retryable }), options.cause ? { cause: options.cause } : undefined)
|
|
69
328
|
this.name = 'TowerToolError'
|
|
70
329
|
this.code = code
|
|
71
|
-
this.retryable =
|
|
72
|
-
this.nextAction =
|
|
330
|
+
this.retryable = retryable
|
|
331
|
+
this.nextAction = nextAction
|
|
73
332
|
this.details = options.details ?? null
|
|
74
333
|
}
|
|
75
334
|
}
|
|
76
335
|
|
|
336
|
+
/** @param {any} error */
|
|
77
337
|
function originalMessage(error) {
|
|
78
338
|
return String(error?.message ?? error)
|
|
79
339
|
}
|
|
80
340
|
|
|
81
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* Wrap any thrown value into a TowerToolError with a stable code.
|
|
343
|
+
* @param {any} error
|
|
344
|
+
*/
|
|
82
345
|
export function classifyError(error) {
|
|
83
346
|
if (error instanceof TowerToolError) return error
|
|
84
347
|
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
85
348
|
return new TowerToolError(error.code, originalMessage(error), {
|
|
86
349
|
cause: error,
|
|
87
|
-
|
|
88
|
-
|
|
350
|
+
// Let the catalog supply a default when the thrown object does not
|
|
351
|
+
// explicitly override the recovery metadata.
|
|
352
|
+
retryable: typeof error.retryable === 'boolean' ? error.retryable : undefined,
|
|
353
|
+
nextAction: error.nextAction ?? undefined,
|
|
89
354
|
details: error.details ?? null,
|
|
90
355
|
})
|
|
91
356
|
}
|