@quolu/lattice 0.71.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lattice-mcp.mjs +2 -2
- package/bin/lattice.mjs +38 -2
- package/package.json +1 -1
- package/src/cli-command-kind.mjs +20 -0
- package/src/runtime-errors.mjs +49 -6
- 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
|
@@ -8,6 +8,10 @@ import packageJson from '../package.json' with { type: 'json' };
|
|
|
8
8
|
installPipeCloseGuard();
|
|
9
9
|
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
|
+
// 作業ディレクトリを読まないsurface。cwdが消えた席(閉じたworktree)からでも動かす。
|
|
12
|
+
const CWD_FREE_COMMANDS = new Set(['runtime-errors', 'bridge', 'hooks', 'setup', 'factory-diagnostics']);
|
|
13
|
+
// 内部故障の観測(下のobserveInternalFailure)が終わるのを、exit前に待つための置き場。
|
|
14
|
+
let pendingObservation = Promise.resolve();
|
|
11
15
|
installSqliteExperimentalWarningFilter();
|
|
12
16
|
const help = renderCliHelp(args);
|
|
13
17
|
|
|
@@ -15,6 +19,16 @@ if (help !== null) {
|
|
|
15
19
|
process.stdout.write(help);
|
|
16
20
|
} else if (args.length === 1 && args[0] === '--version') {
|
|
17
21
|
process.stdout.write(`${packageJson.version}\n`);
|
|
22
|
+
} else if (!CWD_FREE_COMMANDS.has(args[0]) && workingDirectory() === null) {
|
|
23
|
+
// 呼び出し元のcwdが削除済み(閉じたworktreeに残ったshell等)。`process.cwd()`のENOENTが
|
|
24
|
+
// 各分岐で生のstackや内部故障(LATTICE.CLI_INTERNAL_FAILED)として漏れていた。製品の故障ではなく
|
|
25
|
+
// 呼び出し環境の不備なので、観測せずtyped契約で原因と直し方を返す。
|
|
26
|
+
process.stderr.write(`${JSON.stringify({
|
|
27
|
+
schema: 'lattice.cli_error.v2', code: 'CWD_UNAVAILABLE',
|
|
28
|
+
message: 'current working directory no longer exists',
|
|
29
|
+
detail: { next_action: 'cd_to_existing_project_directory_then_retry' },
|
|
30
|
+
})}\n`);
|
|
31
|
+
process.exitCode = 1;
|
|
18
32
|
} else if (args.length === 2 && args[0] === 'session-context' && args[1] === '--json') {
|
|
19
33
|
const { runSessionContext } = await import('../src/project-cli.mjs');
|
|
20
34
|
process.exitCode = await runSessionContext({
|
|
@@ -117,7 +131,9 @@ if (help !== null) {
|
|
|
117
131
|
cwd: process.cwd(),
|
|
118
132
|
stdout: process.stdout,
|
|
119
133
|
stderr: process.stderr,
|
|
134
|
+
onInternalFailure: (error, commandKind) => observeInternalFailure(error, commandKind),
|
|
120
135
|
});
|
|
136
|
+
await pendingObservation;
|
|
121
137
|
} else if (args[0] === 'bridge') {
|
|
122
138
|
const { runBridgeCli } = await import('../src/bridge-cli.mjs');
|
|
123
139
|
process.exitCode = await runBridgeCli({
|
|
@@ -142,8 +158,9 @@ if (help !== null) {
|
|
|
142
158
|
});
|
|
143
159
|
} catch (error) {
|
|
144
160
|
// typed契約(cli_error.v2+exit 1/2)の外へ漏れた例外=内部故障。opt-in時のみ観測を残す。
|
|
145
|
-
const {
|
|
146
|
-
|
|
161
|
+
const { cliCommandKind } = await import('../src/cli-command-kind.mjs');
|
|
162
|
+
observeInternalFailure(error, cliCommandKind(args));
|
|
163
|
+
await pendingObservation;
|
|
147
164
|
// **理由を捨てない。** 型名だけでは、何が起きたかを追う手段が無い——実際、seam解決の
|
|
148
165
|
// `witness_set_invalid`はAPIを直接叩くまで見えなかった。typed契約の外へ漏れたこと自体は
|
|
149
166
|
// 内部故障だが、漏れた中身は残す。
|
|
@@ -160,6 +177,25 @@ if (help !== null) {
|
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
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
|
+
|
|
190
|
+
function workingDirectory() {
|
|
191
|
+
try {
|
|
192
|
+
return process.cwd();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error?.code === 'ENOENT') return null;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
async function runRuntimeErrorsCli(rest) {
|
|
164
200
|
const runtimeErrors = await import('../src/runtime-errors.mjs');
|
|
165
201
|
const usage = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
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,15 @@ 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
|
+
|
|
40
49
|
const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
50
|
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
42
51
|
const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
@@ -86,9 +95,35 @@ export function runtimeCollectionEnabled(env = process.env, configPath) {
|
|
|
86
95
|
return collectionEnabled({ env, configPath });
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
98
|
+
const validCommandKind = (value) => typeof value === 'string' && value.length <= COMMAND_KIND_MAX && COMMAND_KIND.test(value);
|
|
99
|
+
const validErrorKind = (value) => value === 'other' || ERROR_KINDS.has(value);
|
|
100
|
+
const validCauseCode = (value) => value === 'none' || (typeof value === 'string' && CAUSE_CODE.test(value));
|
|
101
|
+
const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KEYS)
|
|
102
|
+
&& validCommandKind(value.command_kind) && validErrorKind(value.error_kind) && validCauseCode(value.cause_code);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 落ちた面と例外から、記録へ載せる固定語彙の分類を作る。語彙に無い値は`other`/`none`へ落とす
|
|
106
|
+
* ——呼び出し側が何を渡しても、利用者の入力やpathが記録へ入らない。
|
|
107
|
+
*/
|
|
108
|
+
export function runtimeErrorSafeContext({ commandKind, error } = {}) {
|
|
109
|
+
const errorKind = error?.constructor?.name;
|
|
110
|
+
return {
|
|
111
|
+
command_kind: validCommandKind(commandKind) ? commandKind : 'other',
|
|
112
|
+
error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
|
|
113
|
+
cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
|
|
119
|
+
* ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
|
|
120
|
+
* 連結順・NUL区切り・末尾区切り無しを変えない。
|
|
121
|
+
*/
|
|
122
|
+
function fingerprintOf(code, safeContext = null) {
|
|
90
123
|
const definition = definitions[code];
|
|
91
|
-
|
|
124
|
+
const parts = [PRODUCT, definition.component, code, definition.template];
|
|
125
|
+
if (safeContext !== null) parts.push(...SAFE_CONTEXT_KEYS.map((key) => safeContext[key]));
|
|
126
|
+
return createHash('sha256').update(parts.join('\0')).digest('hex');
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
function assertPosix(info, mode) {
|
|
@@ -109,6 +144,8 @@ function ensureSafeFile(path) {
|
|
|
109
144
|
assertPosix(statSync(path), 0o600);
|
|
110
145
|
}
|
|
111
146
|
|
|
147
|
+
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']);
|
|
148
|
+
|
|
112
149
|
const empty = () => ({ schema: RUNTIME_ERRORS_SCHEMA, next_sequence: 1, acknowledged_through: 0, records: [] });
|
|
113
150
|
|
|
114
151
|
function validate(store) {
|
|
@@ -119,11 +156,13 @@ function validate(store) {
|
|
|
119
156
|
const seen = new Set();
|
|
120
157
|
let previous = 0;
|
|
121
158
|
for (const record of store.records) {
|
|
122
|
-
if (!plain(record) || !exact(record,
|
|
159
|
+
if (!plain(record) || (!exact(record, RECORD_KEYS) && !exact(record, [...RECORD_KEYS, 'safe_context']))) throw Error('state_invalid');
|
|
160
|
+
const safeContext = Object.hasOwn(record, 'safe_context') ? record.safe_context : null;
|
|
161
|
+
if (safeContext !== null && !validSafeContext(safeContext)) throw Error('state_invalid');
|
|
123
162
|
const definition = definitions[record.error_code];
|
|
124
163
|
if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
|
|
125
164
|
|| record.component !== definition.component || record.message_template !== definition.template
|
|
126
|
-
|| record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code)
|
|
165
|
+
|| record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
|
|
127
166
|
|| seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
|
|
128
167
|
|| !validTime(record.first_seen) || !validTime(record.last_seen)
|
|
129
168
|
|| Date.parse(record.first_seen) > Date.parse(record.last_seen)
|
|
@@ -219,7 +258,7 @@ function snapshot(options = {}) {
|
|
|
219
258
|
version: options.version ?? 'unknown',
|
|
220
259
|
state_schema_version: STATE_VERSION,
|
|
221
260
|
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 })),
|
|
261
|
+
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
262
|
resolutions: rows.filter((record) => record.status === 'resolved').map(({ fingerprint, resolved_at, reason_code }) => ({ fingerprint, resolved_at, reason_code })),
|
|
224
263
|
diagnostics: {
|
|
225
264
|
collection: enabled ? 'enabled' : 'disabled',
|
|
@@ -264,7 +303,10 @@ export function recordRuntimeError(code, options = {}) {
|
|
|
264
303
|
const { path } = optionsFor(options);
|
|
265
304
|
return lock(path, () => {
|
|
266
305
|
const store = readStore(path);
|
|
267
|
-
|
|
306
|
+
// 新しい発生は必ず分類つきの記録へ入る。分類を渡さない呼び出しは`other`/`none`で埋める。
|
|
307
|
+
const safeContext = options.safeContext === undefined ? runtimeErrorSafeContext() : options.safeContext;
|
|
308
|
+
if (!validSafeContext(safeContext)) throw Error('invalid_safe_context');
|
|
309
|
+
const key = fingerprintOf(code, safeContext);
|
|
268
310
|
const sequence = store.next_sequence++;
|
|
269
311
|
const time = now(options);
|
|
270
312
|
const version = options.version ?? '0.0.0';
|
|
@@ -289,6 +331,7 @@ export function recordRuntimeError(code, options = {}) {
|
|
|
289
331
|
message_template: definition.template, severity: definition.severity, fingerprint: key,
|
|
290
332
|
count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
|
|
291
333
|
os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
|
|
334
|
+
safe_context: { ...safeContext },
|
|
292
335
|
});
|
|
293
336
|
}
|
|
294
337
|
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)}/` };
|