@quolu/lattice 0.71.1 → 0.72.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/bin/lattice-mcp.mjs +2 -2
- package/bin/lattice.mjs +17 -2
- package/package.json +1 -1
- package/src/cli-command-kind.mjs +20 -0
- package/src/runtime-errors.mjs +53 -7
- package/src/todo-cli.mjs +16 -5
- package/src/todo-dashboard-registry.mjs +26 -33
package/bin/lattice-mcp.mjs
CHANGED
|
@@ -126,8 +126,8 @@ if (nodeVersionGuard.blocked) {
|
|
|
126
126
|
await server.start();
|
|
127
127
|
} catch (err) {
|
|
128
128
|
// opt-in runtime error store(既定OFF・config有効時のみ)。観測失敗はstderr一行のbest-effort。
|
|
129
|
-
const { observeRuntimeError } = await import('../src/runtime-errors.mjs');
|
|
130
|
-
observeRuntimeError('LATTICE.MCP_SERVER_FAILED');
|
|
129
|
+
const { observeRuntimeError, runtimeErrorSafeContext } = await import('../src/runtime-errors.mjs');
|
|
130
|
+
observeRuntimeError('LATTICE.MCP_SERVER_FAILED', { safeContext: runtimeErrorSafeContext({ error: err }) });
|
|
131
131
|
startupFailure('MCP_STARTUP_FAILED', err instanceof Error ? err.message : String(err));
|
|
132
132
|
}
|
|
133
133
|
}
|
package/bin/lattice.mjs
CHANGED
|
@@ -10,6 +10,8 @@ installPipeCloseGuard();
|
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
11
|
// 作業ディレクトリを読まないsurface。cwdが消えた席(閉じたworktree)からでも動かす。
|
|
12
12
|
const CWD_FREE_COMMANDS = new Set(['runtime-errors', 'bridge', 'hooks', 'setup', 'factory-diagnostics']);
|
|
13
|
+
// 内部故障の観測(下のobserveInternalFailure)が終わるのを、exit前に待つための置き場。
|
|
14
|
+
let pendingObservation = Promise.resolve();
|
|
13
15
|
installSqliteExperimentalWarningFilter();
|
|
14
16
|
const help = renderCliHelp(args);
|
|
15
17
|
|
|
@@ -129,7 +131,9 @@ if (help !== null) {
|
|
|
129
131
|
cwd: process.cwd(),
|
|
130
132
|
stdout: process.stdout,
|
|
131
133
|
stderr: process.stderr,
|
|
134
|
+
onInternalFailure: (error, commandKind) => observeInternalFailure(error, commandKind),
|
|
132
135
|
});
|
|
136
|
+
await pendingObservation;
|
|
133
137
|
} else if (args[0] === 'bridge') {
|
|
134
138
|
const { runBridgeCli } = await import('../src/bridge-cli.mjs');
|
|
135
139
|
process.exitCode = await runBridgeCli({
|
|
@@ -154,8 +158,9 @@ if (help !== null) {
|
|
|
154
158
|
});
|
|
155
159
|
} catch (error) {
|
|
156
160
|
// typed契約(cli_error.v2+exit 1/2)の外へ漏れた例外=内部故障。opt-in時のみ観測を残す。
|
|
157
|
-
const {
|
|
158
|
-
|
|
161
|
+
const { cliCommandKind } = await import('../src/cli-command-kind.mjs');
|
|
162
|
+
observeInternalFailure(error, cliCommandKind(args));
|
|
163
|
+
await pendingObservation;
|
|
159
164
|
// **理由を捨てない。** 型名だけでは、何が起きたかを追う手段が無い——実際、seam解決の
|
|
160
165
|
// `witness_set_invalid`はAPIを直接叩くまで見えなかった。typed契約の外へ漏れたこと自体は
|
|
161
166
|
// 内部故障だが、漏れた中身は残す。
|
|
@@ -172,6 +177,16 @@ if (help !== null) {
|
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
// typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
|
|
181
|
+
// 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
|
|
182
|
+
function observeInternalFailure(error, commandKind) {
|
|
183
|
+
pendingObservation = import('../src/runtime-errors.mjs').then(({ observeRuntimeError, runtimeErrorSafeContext }) => {
|
|
184
|
+
observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', {
|
|
185
|
+
version: packageJson.version, safeContext: runtimeErrorSafeContext({ commandKind, error }),
|
|
186
|
+
});
|
|
187
|
+
}).catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
|
|
175
190
|
function workingDirectory() {
|
|
176
191
|
try {
|
|
177
192
|
return process.cwd();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.1",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 落ちたCLIの面を、固定の語彙(`<surface>`か`<surface>.<subcommand>`)へ写す。
|
|
3
|
+
*
|
|
4
|
+
* runtime error記録の`safe_context.command_kind`に載る値で、工場reportを通ってBugHubまで運ばれる。
|
|
5
|
+
* 利用者が打った引数の値(run ref・path・plan key等)は載せない——この一覧に在る語だけを返し、
|
|
6
|
+
* 一覧に無い語は面の名前だけ、面も無ければ`other`へ落とす。
|
|
7
|
+
*/
|
|
8
|
+
const RUNTIME_SURFACES = Object.freeze({
|
|
9
|
+
plan: Object.freeze(['compile', 'verify']),
|
|
10
|
+
run: Object.freeze(['abandon', 'activate', 'adapter', 'close', 'conflict', 'finding', 'hold', 'intake',
|
|
11
|
+
'landing', 'list', 'observe', 'recompile', 'reprocess', 'request', 'resume', 'seam', 'start', 'status']),
|
|
12
|
+
event: Object.freeze(['verify']),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function cliCommandKind(argv, surfaces = RUNTIME_SURFACES) {
|
|
16
|
+
if (!Array.isArray(argv)) return 'other';
|
|
17
|
+
const [surface, subcommand] = argv;
|
|
18
|
+
if (typeof surface !== 'string' || !Object.hasOwn(surfaces, surface)) return 'other';
|
|
19
|
+
return surfaces[surface].includes(subcommand) ? `${surface}.${subcommand}` : surface;
|
|
20
|
+
}
|
package/src/runtime-errors.mjs
CHANGED
|
@@ -37,6 +37,18 @@ const definitions = Object.freeze({
|
|
|
37
37
|
'LATTICE.MCP_SERVER_FAILED': { component: 'mcp', severity: 'high', template: 'Lattice MCP server failed' },
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
// safe_context(工場wireの任意欄)。固定語彙だけを載せ、message本文・path・引数の値・stackは載せない。
|
|
41
|
+
// 3つのキーは必ずそろえる——fingerprintがこの3つを含むので、欠けると記録を一意に決められない。
|
|
42
|
+
const SAFE_CONTEXT_KEYS = Object.freeze(['command_kind', 'error_kind', 'cause_code']);
|
|
43
|
+
const ERROR_KINDS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError', 'AggregateError', 'SystemError']);
|
|
44
|
+
const COMMAND_KIND = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)?$/;
|
|
45
|
+
const COMMAND_KIND_MAX = 48;
|
|
46
|
+
// Nodeが付けるerror code(ENOENT・ERR_MODULE_NOT_FOUND等)だけを通す。Lattice自身のcodeや任意文字列は`none`。
|
|
47
|
+
const CAUSE_CODE = /^(?:E[A-Z0-9]{2,15}|ERR_[A-Z0-9_]{1,60})$/;
|
|
48
|
+
// 工場の`factory-reporter.json`が名乗る端末の種類。dotagentsの契約(lib/factory/contract.mjs)と同じ語を
|
|
49
|
+
// 受ける——ここに無い語の端末は設定全体が無効とみなされ、故障を1件も記録しない。
|
|
50
|
+
const HOST_PROFILES = Object.freeze(['server', 'mac', 'linux', 'wsl', 'windows-native']);
|
|
51
|
+
|
|
40
52
|
const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
53
|
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
42
54
|
const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
@@ -74,7 +86,7 @@ function collectionEnabled(options = {}) {
|
|
|
74
86
|
return plain(config) && exact(config, ['schema_version', 'host', 'collection', 'reporting'])
|
|
75
87
|
&& config.schema_version === '1.0' && plain(config.host) && exact(config.host, ['id', 'profile'])
|
|
76
88
|
&& typeof config.host.id === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(config.host.id)
|
|
77
|
-
&&
|
|
89
|
+
&& HOST_PROFILES.includes(config.host.profile)
|
|
78
90
|
&& plain(config.collection) && exact(config.collection, ['enabled']) && config.collection.enabled === true
|
|
79
91
|
&& canonicalReporting(config.reporting);
|
|
80
92
|
} catch {
|
|
@@ -86,9 +98,35 @@ export function runtimeCollectionEnabled(env = process.env, configPath) {
|
|
|
86
98
|
return collectionEnabled({ env, configPath });
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
|
|
101
|
+
const validCommandKind = (value) => typeof value === 'string' && value.length <= COMMAND_KIND_MAX && COMMAND_KIND.test(value);
|
|
102
|
+
const validErrorKind = (value) => value === 'other' || ERROR_KINDS.has(value);
|
|
103
|
+
const validCauseCode = (value) => value === 'none' || (typeof value === 'string' && CAUSE_CODE.test(value));
|
|
104
|
+
const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KEYS)
|
|
105
|
+
&& validCommandKind(value.command_kind) && validErrorKind(value.error_kind) && validCauseCode(value.cause_code);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 落ちた面と例外から、記録へ載せる固定語彙の分類を作る。語彙に無い値は`other`/`none`へ落とす
|
|
109
|
+
* ——呼び出し側が何を渡しても、利用者の入力やpathが記録へ入らない。
|
|
110
|
+
*/
|
|
111
|
+
export function runtimeErrorSafeContext({ commandKind, error } = {}) {
|
|
112
|
+
const errorKind = error?.constructor?.name;
|
|
113
|
+
return {
|
|
114
|
+
command_kind: validCommandKind(commandKind) ? commandKind : 'other',
|
|
115
|
+
error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
|
|
116
|
+
cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
|
|
122
|
+
* ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
|
|
123
|
+
* 連結順・NUL区切り・末尾区切り無しを変えない。
|
|
124
|
+
*/
|
|
125
|
+
function fingerprintOf(code, safeContext = null) {
|
|
90
126
|
const definition = definitions[code];
|
|
91
|
-
|
|
127
|
+
const parts = [PRODUCT, definition.component, code, definition.template];
|
|
128
|
+
if (safeContext !== null) parts.push(...SAFE_CONTEXT_KEYS.map((key) => safeContext[key]));
|
|
129
|
+
return createHash('sha256').update(parts.join('\0')).digest('hex');
|
|
92
130
|
}
|
|
93
131
|
|
|
94
132
|
function assertPosix(info, mode) {
|
|
@@ -109,6 +147,8 @@ function ensureSafeFile(path) {
|
|
|
109
147
|
assertPosix(statSync(path), 0o600);
|
|
110
148
|
}
|
|
111
149
|
|
|
150
|
+
const RECORD_KEYS = Object.freeze(['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence']);
|
|
151
|
+
|
|
112
152
|
const empty = () => ({ schema: RUNTIME_ERRORS_SCHEMA, next_sequence: 1, acknowledged_through: 0, records: [] });
|
|
113
153
|
|
|
114
154
|
function validate(store) {
|
|
@@ -119,11 +159,13 @@ function validate(store) {
|
|
|
119
159
|
const seen = new Set();
|
|
120
160
|
let previous = 0;
|
|
121
161
|
for (const record of store.records) {
|
|
122
|
-
if (!plain(record) || !exact(record,
|
|
162
|
+
if (!plain(record) || (!exact(record, RECORD_KEYS) && !exact(record, [...RECORD_KEYS, 'safe_context']))) throw Error('state_invalid');
|
|
163
|
+
const safeContext = Object.hasOwn(record, 'safe_context') ? record.safe_context : null;
|
|
164
|
+
if (safeContext !== null && !validSafeContext(safeContext)) throw Error('state_invalid');
|
|
123
165
|
const definition = definitions[record.error_code];
|
|
124
166
|
if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
|
|
125
167
|
|| record.component !== definition.component || record.message_template !== definition.template
|
|
126
|
-
|| record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code)
|
|
168
|
+
|| record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
|
|
127
169
|
|| seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
|
|
128
170
|
|| !validTime(record.first_seen) || !validTime(record.last_seen)
|
|
129
171
|
|| Date.parse(record.first_seen) > Date.parse(record.last_seen)
|
|
@@ -219,7 +261,7 @@ function snapshot(options = {}) {
|
|
|
219
261
|
version: options.version ?? 'unknown',
|
|
220
262
|
state_schema_version: STATE_VERSION,
|
|
221
263
|
cursor: { high_watermark: store.next_sequence - 1, acknowledged_through: store.acknowledged_through, next: rows.at(-1)?.sequence ?? afterCursor },
|
|
222
|
-
runtime_errors: rows.filter((record) => record.status === 'open').map(({ product_version, error_code, component, status, severity, fingerprint, message_template, count, first_seen, last_seen, state_schema_version }) => ({ product_version, error_code, component, status, severity, fingerprint, message_template, occurrence_count: count, first_seen, last_seen, state_schema_version })),
|
|
264
|
+
runtime_errors: rows.filter((record) => record.status === 'open').map(({ product_version, error_code, component, status, severity, fingerprint, message_template, count, first_seen, last_seen, state_schema_version, safe_context }) => ({ product_version, error_code, component, status, severity, fingerprint, message_template, occurrence_count: count, first_seen, last_seen, state_schema_version, ...(safe_context === undefined ? {} : { safe_context }) })),
|
|
223
265
|
resolutions: rows.filter((record) => record.status === 'resolved').map(({ fingerprint, resolved_at, reason_code }) => ({ fingerprint, resolved_at, reason_code })),
|
|
224
266
|
diagnostics: {
|
|
225
267
|
collection: enabled ? 'enabled' : 'disabled',
|
|
@@ -264,7 +306,10 @@ export function recordRuntimeError(code, options = {}) {
|
|
|
264
306
|
const { path } = optionsFor(options);
|
|
265
307
|
return lock(path, () => {
|
|
266
308
|
const store = readStore(path);
|
|
267
|
-
|
|
309
|
+
// 新しい発生は必ず分類つきの記録へ入る。分類を渡さない呼び出しは`other`/`none`で埋める。
|
|
310
|
+
const safeContext = options.safeContext === undefined ? runtimeErrorSafeContext() : options.safeContext;
|
|
311
|
+
if (!validSafeContext(safeContext)) throw Error('invalid_safe_context');
|
|
312
|
+
const key = fingerprintOf(code, safeContext);
|
|
268
313
|
const sequence = store.next_sequence++;
|
|
269
314
|
const time = now(options);
|
|
270
315
|
const version = options.version ?? '0.0.0';
|
|
@@ -289,6 +334,7 @@ export function recordRuntimeError(code, options = {}) {
|
|
|
289
334
|
message_template: definition.template, severity: definition.severity, fingerprint: key,
|
|
290
335
|
count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
|
|
291
336
|
os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
|
|
337
|
+
safe_context: { ...safeContext },
|
|
292
338
|
});
|
|
293
339
|
}
|
|
294
340
|
store.records.sort((a, b) => a.sequence - b.sequence);
|
package/src/todo-cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cliCommandKind } from './cli-command-kind.mjs';
|
|
1
2
|
import { gitSync } from './git-process.mjs';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import {
|
|
@@ -3755,12 +3756,22 @@ async function repairStoreEol({ repoRoot }) {
|
|
|
3755
3756
|
* `lattice todo` namespace. Exact position, order, and argument count are part of
|
|
3756
3757
|
* the public contract; usage failures never use a JSON envelope.
|
|
3757
3758
|
*/
|
|
3758
|
-
export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
3759
|
+
export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env,
|
|
3760
|
+
onInternalFailure = null }) {
|
|
3759
3761
|
if (!Array.isArray(argv) || typeof cwd !== 'string'
|
|
3760
3762
|
|| typeof stdout?.write !== 'function' || typeof stderr?.write !== 'function'
|
|
3761
|
-
|| env === null || typeof env !== 'object' || Array.isArray(env)
|
|
3763
|
+
|| env === null || typeof env !== 'object' || Array.isArray(env)
|
|
3764
|
+
|| (onInternalFailure !== null && typeof onInternalFailure !== 'function')) {
|
|
3762
3765
|
throw new TypeError('runTodoCli optionsが不正');
|
|
3763
3766
|
}
|
|
3767
|
+
// typed契約の外へ漏れた例外は、binが渡す観測口へも知らせる(opt-inのruntime error記録)。
|
|
3768
|
+
// 観測口を持つのはprocess境界のbinだけ——このmoduleを直接呼ぶ試験や埋め込みは記録しない。
|
|
3769
|
+
const failInternally = (error) => {
|
|
3770
|
+
try {
|
|
3771
|
+
onInternalFailure?.(error, cliCommandKind(['todo', ...argv], { todo: TODO_COMMAND_NAMES }));
|
|
3772
|
+
} catch { /* 観測の失敗でCLIの応答を変えない */ }
|
|
3773
|
+
return internalFailure(stderr, error);
|
|
3774
|
+
};
|
|
3764
3775
|
|
|
3765
3776
|
const atomicCommit = argv.at(-1) === '--commit-store';
|
|
3766
3777
|
if (atomicCommit) argv = argv.slice(0, -1);
|
|
@@ -3777,7 +3788,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
3777
3788
|
await runTodoSchemaCommand(argv[0], stdout);
|
|
3778
3789
|
return 0;
|
|
3779
3790
|
} catch (error) {
|
|
3780
|
-
return
|
|
3791
|
+
return failInternally(error);
|
|
3781
3792
|
}
|
|
3782
3793
|
}
|
|
3783
3794
|
|
|
@@ -3799,7 +3810,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
3799
3810
|
} catch (error) {
|
|
3800
3811
|
if (typeof error?.code === 'string' && error.detail !== null
|
|
3801
3812
|
&& typeof error.detail === 'object') return typedFailure(stderr, error);
|
|
3802
|
-
return
|
|
3813
|
+
return failInternally(error);
|
|
3803
3814
|
}
|
|
3804
3815
|
}
|
|
3805
3816
|
|
|
@@ -4248,6 +4259,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
4248
4259
|
message: error.message,
|
|
4249
4260
|
});
|
|
4250
4261
|
}
|
|
4251
|
-
return
|
|
4262
|
+
return failInternally(error);
|
|
4252
4263
|
}
|
|
4253
4264
|
}
|
|
@@ -68,16 +68,12 @@ function validateRegistry(value) {
|
|
|
68
68
|
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
69
69
|
|| value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
|
|
70
70
|
|| !value.projects.every(validEntry)) {
|
|
71
|
-
|
|
72
|
-
error.code = 'DASHBOARD_REGISTRY_INVALID';
|
|
73
|
-
throw error;
|
|
71
|
+
throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry schema invalid', { retry: false });
|
|
74
72
|
}
|
|
75
73
|
const ids = new Set();
|
|
76
74
|
for (const entry of value.projects) {
|
|
77
75
|
if (ids.has(entry.project_id)) {
|
|
78
|
-
|
|
79
|
-
error.code = 'DASHBOARD_REGISTRY_INVALID';
|
|
80
|
-
throw error;
|
|
76
|
+
throw dashboardError('DASHBOARD_REGISTRY_INVALID', `dashboard project duplicate: ${entry.project_id}`, { retry: false });
|
|
81
77
|
}
|
|
82
78
|
ids.add(entry.project_id);
|
|
83
79
|
}
|
|
@@ -91,12 +87,25 @@ async function readJson(ref, missing) {
|
|
|
91
87
|
throw error;
|
|
92
88
|
}
|
|
93
89
|
try { return JSON.parse(bytes); } catch {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
throw error;
|
|
90
|
+
// local absolute pathをCLI errorへ運ばない。どのfileかは登録簿の置き場から決まる。
|
|
91
|
+
throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry file is not valid JSON', { retry: false });
|
|
97
92
|
}
|
|
98
93
|
}
|
|
99
94
|
|
|
95
|
+
/**
|
|
96
|
+
* 登録簿とdaemonの故障は、外部状態(別process・file・負荷)の不整合であって内部故障ではない。
|
|
97
|
+
* codeだけのErrorはCLIのtyped契約(code+detail)に乗らず`INTERNAL_FAILURE`へ落ちていた
|
|
98
|
+
* ——launchdの`todo dashboard ensure`が、負荷時のlock待ち切れやdaemonの無応答をそう返していた。
|
|
99
|
+
* detailを必ず持たせ、再実行で解ける種類かどうかを`next_action`で伝える。
|
|
100
|
+
*/
|
|
101
|
+
function dashboardError(code, message, { retry = true } = {}) {
|
|
102
|
+
const error = new Error(message);
|
|
103
|
+
error.code = code;
|
|
104
|
+
error.detail = { reason: code.toLowerCase(),
|
|
105
|
+
next_action: retry ? 'lattice todo dashboard ensure --json' : null };
|
|
106
|
+
return error;
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
async function atomicJson(ref, value) {
|
|
101
110
|
const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
102
111
|
await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx', flush: true });
|
|
@@ -131,9 +140,7 @@ async function withLock(lockRef, action) {
|
|
|
131
140
|
await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
|
-
|
|
135
|
-
error.code = 'DASHBOARD_REGISTRY_BUSY';
|
|
136
|
-
throw error;
|
|
143
|
+
throw dashboardError('DASHBOARD_REGISTRY_BUSY', 'dashboard registry lock timeout');
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
export async function readActiveTodoDashboardProjects({ env = process.env, now = Date.now() } = {}) {
|
|
@@ -414,15 +421,11 @@ async function stopAttestedLegacyDaemon(descriptor, {
|
|
|
414
421
|
const attestation = await daemonAttestation(descriptor);
|
|
415
422
|
if (attestation !== 'legacy') {
|
|
416
423
|
if (!await isProcessAlive(descriptor.pid) && attestation === null) return;
|
|
417
|
-
|
|
418
|
-
error.code = 'DASHBOARD_LEGACY_ATTESTATION_LOST';
|
|
419
|
-
throw error;
|
|
424
|
+
throw dashboardError('DASHBOARD_LEGACY_ATTESTATION_LOST', 'legacy dashboard daemon attestation was lost before signal');
|
|
420
425
|
}
|
|
421
426
|
if (!await requestDaemonShutdown(descriptor)) signalProcess(descriptor.pid, 'SIGTERM');
|
|
422
427
|
if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return;
|
|
423
|
-
|
|
424
|
-
error.code = 'DASHBOARD_LEGACY_STOP_FAILED';
|
|
425
|
-
throw error;
|
|
428
|
+
throw dashboardError('DASHBOARD_LEGACY_STOP_FAILED', 'legacy dashboard daemon did not stop');
|
|
426
429
|
}
|
|
427
430
|
|
|
428
431
|
/**
|
|
@@ -444,9 +447,7 @@ async function stopStrayDaemon(descriptor, {
|
|
|
444
447
|
if (await awaitDaemonStopped(descriptor, {
|
|
445
448
|
isProcessAlive, deadline: Date.now() + (timeoutMs - half),
|
|
446
449
|
})) return true;
|
|
447
|
-
|
|
448
|
-
error.code = 'DASHBOARD_ORPHAN_STOP_FAILED';
|
|
449
|
-
throw error;
|
|
450
|
+
throw dashboardError('DASHBOARD_ORPHAN_STOP_FAILED', 'stray dashboard daemon did not stop');
|
|
450
451
|
}
|
|
451
452
|
|
|
452
453
|
/**
|
|
@@ -493,9 +494,7 @@ async function stopSpawnedReplacement(child, descriptor, {
|
|
|
493
494
|
if (await stopped()) return;
|
|
494
495
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
495
496
|
}
|
|
496
|
-
|
|
497
|
-
error.code = 'DASHBOARD_REPLACEMENT_ROLLBACK_FAILED';
|
|
498
|
-
throw error;
|
|
497
|
+
throw dashboardError('DASHBOARD_REPLACEMENT_ROLLBACK_FAILED', 'replacement dashboard daemon rollback did not stop');
|
|
499
498
|
}
|
|
500
499
|
|
|
501
500
|
function currentDaemonRecord(port) {
|
|
@@ -545,9 +544,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
|
|
|
545
544
|
if (validDaemonDescriptor(existing) && existingAttestation === null
|
|
546
545
|
&& await isProcessAlive(existing.pid)
|
|
547
546
|
&& !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) {
|
|
548
|
-
|
|
549
|
-
error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
|
|
550
|
-
throw error;
|
|
547
|
+
throw dashboardError('DASHBOARD_DAEMON_UNRESPONSIVE', 'dashboard daemon is alive but temporarily unresponsive');
|
|
551
548
|
}
|
|
552
549
|
let legacy = existingAttestation === 'legacy' ? existing : null;
|
|
553
550
|
if (legacy === null) {
|
|
@@ -610,9 +607,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
|
|
|
610
607
|
}
|
|
611
608
|
child.kill?.('SIGTERM');
|
|
612
609
|
if (legacy === null) await rm(refs.descriptor, { force: true });
|
|
613
|
-
|
|
614
|
-
error.code = 'DASHBOARD_DAEMON_UNAVAILABLE';
|
|
615
|
-
throw error;
|
|
610
|
+
throw dashboardError('DASHBOARD_DAEMON_UNAVAILABLE', 'dashboard daemon did not become ready');
|
|
616
611
|
});
|
|
617
612
|
}
|
|
618
613
|
|
|
@@ -634,9 +629,7 @@ export async function ensureTodoDashboardActivity(options) {
|
|
|
634
629
|
if (!visible) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
635
630
|
}
|
|
636
631
|
if (!visible) {
|
|
637
|
-
|
|
638
|
-
error.code = 'DASHBOARD_PROJECT_UNAVAILABLE';
|
|
639
|
-
throw error;
|
|
632
|
+
throw dashboardError('DASHBOARD_PROJECT_UNAVAILABLE', 'registered project did not become visible');
|
|
640
633
|
}
|
|
641
634
|
return { ...registered, host: '127.0.0.1', port: daemon.port,
|
|
642
635
|
url: `http://127.0.0.1:${daemon.port}/projects/${encodeURIComponent(registered.projectId)}/` };
|