@quolu/lattice 0.12.24 → 0.12.26
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.
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { constants as fsConstants, readFileSync } from 'node:fs';
|
|
5
|
+
import {
|
|
6
|
+
chmod,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
open,
|
|
10
|
+
readFile,
|
|
11
|
+
realpath,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
|
|
18
|
+
import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
|
|
19
|
+
import {
|
|
20
|
+
armStagedWriteLease,
|
|
21
|
+
CONTROLLER_OPERATIONS,
|
|
22
|
+
validateArmedWriteLease,
|
|
23
|
+
validateControllerRequest,
|
|
24
|
+
validateRuntimeAdapterCapabilities,
|
|
25
|
+
validateRuntimeHeartbeatPolicy,
|
|
26
|
+
} from './runtime-controller-protocol.mjs';
|
|
27
|
+
import {
|
|
28
|
+
selfDigest,
|
|
29
|
+
validateExecutorPacket,
|
|
30
|
+
validateExecutorReceipt,
|
|
31
|
+
} from './runtime-contracts.mjs';
|
|
32
|
+
import { validateSupervisorWriteGate } from './runtime-gate-store.mjs';
|
|
33
|
+
import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
|
|
34
|
+
|
|
35
|
+
const MAX_DOCUMENT_BYTES = 8_388_608;
|
|
36
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
37
|
+
const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
const REQUEST_SCHEMA_TO_OPERATION = Object.freeze(Object.fromEntries([
|
|
40
|
+
['lattice.adapter_dispatch_request.v1', 'dispatch'],
|
|
41
|
+
['lattice.adapter_observe_request.v1', 'observe'],
|
|
42
|
+
['lattice.adapter_running_inventory_request.v1', 'inventory'],
|
|
43
|
+
['lattice.adapter_barrier_request.v1', 'barrier'],
|
|
44
|
+
['lattice.adapter_rebind_request.v1', 'rebind'],
|
|
45
|
+
['lattice.adapter_prepare_request.v1', 'prepare'],
|
|
46
|
+
['lattice.adapter_activate_request.v1', 'activate'],
|
|
47
|
+
['lattice.adapter_release_request.v1', 'release'],
|
|
48
|
+
['lattice.adapter_revoke_request.v1', 'revoke'],
|
|
49
|
+
]));
|
|
50
|
+
|
|
51
|
+
export class ScriptedAdapterControllerError extends Error {
|
|
52
|
+
constructor(code, message, detail = {}) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'ScriptedAdapterControllerError';
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.detail = detail;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fail(code, message, detail) {
|
|
61
|
+
throw new ScriptedAdapterControllerError(code, message, detail);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function plain(value) {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
66
|
+
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function exact(value, fields) {
|
|
70
|
+
if (!plain(value)) return false;
|
|
71
|
+
const actual = Object.keys(value).sort();
|
|
72
|
+
const expected = [...fields].sort();
|
|
73
|
+
return actual.length === expected.length
|
|
74
|
+
&& actual.every((field, index) => field === expected[index]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sign(value, field) {
|
|
78
|
+
value[field] = '';
|
|
79
|
+
value[field] = selfDigest(value, field);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sha256Bytes(bytes) {
|
|
84
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function controllerErrorArtifact(error, requestId = null) {
|
|
88
|
+
const artifact = {
|
|
89
|
+
schema: 'lattice.scripted_adapter_error.v1',
|
|
90
|
+
code: error?.code ?? 'SCRIPTED_CONTROLLER_FAILED',
|
|
91
|
+
message: String(error?.message ?? error),
|
|
92
|
+
request_id: typeof requestId === 'string' ? requestId : null,
|
|
93
|
+
detail: plain(error?.detail) ? structuredClone(error.detail) : {},
|
|
94
|
+
error_digest: '',
|
|
95
|
+
};
|
|
96
|
+
artifact.error_digest = selfDigest(artifact, 'error_digest');
|
|
97
|
+
return artifact;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateBootstrap(value) {
|
|
101
|
+
return exact(value, [
|
|
102
|
+
'schema',
|
|
103
|
+
'request_id',
|
|
104
|
+
'run_id',
|
|
105
|
+
'controller_socket_ref',
|
|
106
|
+
'supervisor_socket_ref',
|
|
107
|
+
'supervisor_session_nonce',
|
|
108
|
+
'bootstrap_digest',
|
|
109
|
+
])
|
|
110
|
+
&& value.schema === 'lattice.adapter_controller_bootstrap.v1'
|
|
111
|
+
&& ID.test(value.request_id ?? '')
|
|
112
|
+
&& ID.test(value.run_id ?? '')
|
|
113
|
+
&& typeof value.controller_socket_ref === 'string'
|
|
114
|
+
&& /^supervisor\/controllers\/[0-9A-Za-z][0-9A-Za-z._-]{0,127}\.sock$/u
|
|
115
|
+
.test(value.controller_socket_ref)
|
|
116
|
+
&& value.supervisor_socket_ref === 'supervisor/control.sock'
|
|
117
|
+
&& typeof value.supervisor_session_nonce === 'string'
|
|
118
|
+
&& value.supervisor_session_nonce.length >= 32
|
|
119
|
+
&& SHA256.test(value.bootstrap_digest ?? '')
|
|
120
|
+
&& selfDigest(value, 'bootstrap_digest') === value.bootstrap_digest;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validateHandshakeRequest(value, bootstrap) {
|
|
124
|
+
return exact(value, [
|
|
125
|
+
'schema',
|
|
126
|
+
'request_id',
|
|
127
|
+
'run_id',
|
|
128
|
+
'supervisor_session_nonce',
|
|
129
|
+
'controller_socket_ref',
|
|
130
|
+
'challenge',
|
|
131
|
+
'request_digest',
|
|
132
|
+
])
|
|
133
|
+
&& value.schema === 'lattice.adapter_controller_handshake_request.v1'
|
|
134
|
+
&& ID.test(value.request_id ?? '')
|
|
135
|
+
&& value.run_id === bootstrap.run_id
|
|
136
|
+
&& value.supervisor_session_nonce === bootstrap.supervisor_session_nonce
|
|
137
|
+
&& value.controller_socket_ref === bootstrap.controller_socket_ref
|
|
138
|
+
&& typeof value.challenge === 'string'
|
|
139
|
+
&& value.challenge.length >= 32
|
|
140
|
+
&& SHA256.test(value.request_digest ?? '')
|
|
141
|
+
&& selfDigest(value, 'request_digest') === value.request_digest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeWritePath(value) {
|
|
145
|
+
return typeof value === 'string'
|
|
146
|
+
&& value.length > 0
|
|
147
|
+
&& value === path.posix.normalize(value)
|
|
148
|
+
&& !path.posix.isAbsolute(value)
|
|
149
|
+
&& value !== '..'
|
|
150
|
+
&& !value.startsWith('../')
|
|
151
|
+
&& !value.includes('\0')
|
|
152
|
+
&& !['.git', '.lattice'].includes(value.split('/')[0]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function durableReplaceBytes(filePath, bytes, mode = 0o600) {
|
|
156
|
+
const directory = path.dirname(filePath);
|
|
157
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
158
|
+
const temporaryPath = path.join(directory, `.${path.basename(filePath)}-${process.pid}.tmp`);
|
|
159
|
+
let handle;
|
|
160
|
+
try {
|
|
161
|
+
handle = await open(
|
|
162
|
+
temporaryPath,
|
|
163
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
164
|
+
mode,
|
|
165
|
+
);
|
|
166
|
+
await handle.writeFile(bytes);
|
|
167
|
+
await handle.sync();
|
|
168
|
+
await handle.close();
|
|
169
|
+
handle = null;
|
|
170
|
+
await rename(temporaryPath, filePath);
|
|
171
|
+
await chmod(filePath, mode);
|
|
172
|
+
const directoryHandle = await open(directory, fsConstants.O_RDONLY);
|
|
173
|
+
try {
|
|
174
|
+
await directoryHandle.sync();
|
|
175
|
+
} finally {
|
|
176
|
+
await directoryHandle.close();
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
await handle?.close().catch(() => {});
|
|
180
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function requireSafeTarget(repoRoot, relativePath) {
|
|
185
|
+
if (!safeWritePath(relativePath)) {
|
|
186
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write pathが安全なrepo-relative pathではない', {
|
|
187
|
+
path: relativePath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const target = path.join(repoRoot, ...relativePath.split('/'));
|
|
191
|
+
if (!target.startsWith(`${repoRoot}${path.sep}`)) {
|
|
192
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write pathがrepo外を指す', { path: relativePath });
|
|
193
|
+
}
|
|
194
|
+
let cursor = repoRoot;
|
|
195
|
+
for (const segment of relativePath.split('/').slice(0, -1)) {
|
|
196
|
+
cursor = path.join(cursor, segment);
|
|
197
|
+
try {
|
|
198
|
+
const info = await lstat(cursor);
|
|
199
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
200
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write path祖先がreal directoryではない', {
|
|
201
|
+
path: relativePath,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
210
|
+
const parentReal = await realpath(path.dirname(target));
|
|
211
|
+
if (!parentReal.startsWith(`${repoRoot}${path.sep}`) && parentReal !== repoRoot) {
|
|
212
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write path親がrepo外へ解決された', { path: relativePath });
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const info = await lstat(target);
|
|
216
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
217
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write targetがregular fileではない', {
|
|
218
|
+
path: relativePath,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
223
|
+
}
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function basePathExists(repoRoot, baseSha, relativePath) {
|
|
228
|
+
try {
|
|
229
|
+
await execFileAsync('git', ['cat-file', '-e', `${baseSha}:${relativePath}`], {
|
|
230
|
+
cwd: repoRoot,
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
});
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error?.code === 128) return false;
|
|
236
|
+
fail('SCRIPTED_EXECUTION_FAILED', 'base treeのwrite pathを観測できない', {
|
|
237
|
+
path: relativePath,
|
|
238
|
+
message: String(error?.message ?? error),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function deterministicWriteBytes(packet, relativePath) {
|
|
244
|
+
return Buffer.from(`${canonicalizeArtifact({
|
|
245
|
+
schema: 'lattice.scripted_adapter_write.v1',
|
|
246
|
+
todo_id: packet.todo_id,
|
|
247
|
+
path: relativePath,
|
|
248
|
+
packet_digest: packet.packet_digest,
|
|
249
|
+
context_content_digest: packet.context_content_digest,
|
|
250
|
+
})}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function executePacket({ packet, repoRoot }) {
|
|
254
|
+
const writes = packet.scope?.writes;
|
|
255
|
+
if (!Array.isArray(writes) || writes.length === 0
|
|
256
|
+
|| writes.some((entry, index) => !safeWritePath(entry)
|
|
257
|
+
|| (index > 0 && writes[index - 1] >= entry))) {
|
|
258
|
+
fail('SCRIPTED_PACKET_REJECTED', 'scope.writesは非空の昇順一意pathでなければならない');
|
|
259
|
+
}
|
|
260
|
+
const prepared = [];
|
|
261
|
+
for (const relativePath of writes) {
|
|
262
|
+
const target = await requireSafeTarget(repoRoot, relativePath);
|
|
263
|
+
const existedAtBase = await basePathExists(repoRoot, packet.base_sha, relativePath);
|
|
264
|
+
prepared.push({
|
|
265
|
+
relativePath,
|
|
266
|
+
target,
|
|
267
|
+
bytes: deterministicWriteBytes(packet, relativePath),
|
|
268
|
+
change: existedAtBase ? 'modified' : 'added',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const entry of prepared) {
|
|
272
|
+
await durableReplaceBytes(entry.target, entry.bytes);
|
|
273
|
+
const observed = await readFile(entry.target);
|
|
274
|
+
if (!observed.equals(entry.bytes)) {
|
|
275
|
+
fail('SCRIPTED_EXECUTION_FAILED', 'write後bytesが決定的内容と一致しない', {
|
|
276
|
+
path: entry.relativePath,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const observedDiff = prepared.map((entry) => ({
|
|
281
|
+
path: entry.relativePath,
|
|
282
|
+
change: entry.change,
|
|
283
|
+
}));
|
|
284
|
+
const checkpointDigest = digestArtifact({
|
|
285
|
+
schema: 'lattice.scripted_adapter_checkpoint.v1',
|
|
286
|
+
packet_digest: packet.packet_digest,
|
|
287
|
+
observed_diff: observedDiff,
|
|
288
|
+
content_digests: prepared.map((entry) => ({
|
|
289
|
+
path: entry.relativePath,
|
|
290
|
+
digest: sha256Bytes(entry.bytes),
|
|
291
|
+
})),
|
|
292
|
+
});
|
|
293
|
+
return { observedDiff, checkpointDigest };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function readAndValidateGate(runDir, writeLease) {
|
|
297
|
+
const gatePath = path.join(runDir, 'supervisor', 'write-gate.json');
|
|
298
|
+
let gate;
|
|
299
|
+
try {
|
|
300
|
+
gate = JSON.parse(await readFile(gatePath, 'utf8'));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
fail('SCRIPTED_WRITE_GATE_REJECTED', '中央write gateを読めない', {
|
|
303
|
+
message: String(error?.message ?? error),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (!validateSupervisorWriteGate(gate)
|
|
307
|
+
|| gate.run_id !== writeLease.run_id
|
|
308
|
+
|| gate.plan_epoch !== writeLease.plan_epoch
|
|
309
|
+
|| gate.gate_generation !== writeLease.gate_generation
|
|
310
|
+
|| gate.release_barrier_digest !== writeLease.release_barrier_digest
|
|
311
|
+
|| !gate.armed_lease_digests.includes(writeLease.lease_digest)) {
|
|
312
|
+
fail('SCRIPTED_WRITE_GATE_REJECTED', '中央write gateがarmed leaseを認可しない');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* supervisor controller protocolを実装する決定論的scripted adapter。
|
|
318
|
+
* 時刻はheartbeat schedulingだけに使い、write bytes/handle/receiptへ混入させない。
|
|
319
|
+
*/
|
|
320
|
+
export async function createScriptedAdapterController({
|
|
321
|
+
bootstrap,
|
|
322
|
+
runDir,
|
|
323
|
+
repoRoot,
|
|
324
|
+
controllerSessionNonce = randomBytes(32).toString('hex'),
|
|
325
|
+
}) {
|
|
326
|
+
if (!validateBootstrap(bootstrap)) {
|
|
327
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'controller bootstrapが不正');
|
|
328
|
+
}
|
|
329
|
+
const canonicalRunDir = await realpath(runDir);
|
|
330
|
+
const canonicalRepoRoot = await realpath(repoRoot);
|
|
331
|
+
if (path.resolve(canonicalRunDir, '..', '..', '..') !== canonicalRepoRoot) {
|
|
332
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'run storeとrepo rootのbindingが不正');
|
|
333
|
+
}
|
|
334
|
+
const controllerId = path.basename(bootstrap.controller_socket_ref, '.sock');
|
|
335
|
+
if (!controllerId.startsWith('scripted-')
|
|
336
|
+
|| typeof controllerSessionNonce !== 'string'
|
|
337
|
+
|| controllerSessionNonce.length < 32) {
|
|
338
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'scripted controller identityが不正');
|
|
339
|
+
}
|
|
340
|
+
const capabilities = sign({
|
|
341
|
+
schema: 'lattice.runtime_adapter_capabilities.v1',
|
|
342
|
+
operations: [...CONTROLLER_OPERATIONS],
|
|
343
|
+
process_observation: true,
|
|
344
|
+
worktree_fingerprint: true,
|
|
345
|
+
staged_write_lease: true,
|
|
346
|
+
durable_dispatch: true,
|
|
347
|
+
capabilities_digest: '',
|
|
348
|
+
}, 'capabilities_digest');
|
|
349
|
+
if (!validateRuntimeAdapterCapabilities(capabilities)) {
|
|
350
|
+
fail('SCRIPTED_CONTROLLER_INVALID', 'capabilities生成に失敗した');
|
|
351
|
+
}
|
|
352
|
+
const heartbeat = sign({
|
|
353
|
+
schema: 'lattice.runtime_heartbeat_policy.v1',
|
|
354
|
+
interval_ms: 1_000,
|
|
355
|
+
ttl_ms: 10_000,
|
|
356
|
+
disconnect_revokes_immediately: true,
|
|
357
|
+
policy_digest: '',
|
|
358
|
+
}, 'policy_digest');
|
|
359
|
+
if (!validateRuntimeHeartbeatPolicy(heartbeat)) {
|
|
360
|
+
fail('SCRIPTED_CONTROLLER_INVALID', 'heartbeat policy生成に失敗した');
|
|
361
|
+
}
|
|
362
|
+
const descriptor = sign({
|
|
363
|
+
schema: 'lattice.runtime_adapter_controller_descriptor.v1',
|
|
364
|
+
controller_id: controllerId,
|
|
365
|
+
adapter_kind: 'scripted',
|
|
366
|
+
pid: process.pid,
|
|
367
|
+
process_start_identity: await observeManagedProcessStartIdentity(process.pid),
|
|
368
|
+
socket_ref: bootstrap.controller_socket_ref,
|
|
369
|
+
controller_session_nonce_digest: digestArtifact(controllerSessionNonce),
|
|
370
|
+
capabilities,
|
|
371
|
+
heartbeat,
|
|
372
|
+
descriptor_digest: '',
|
|
373
|
+
}, 'descriptor_digest');
|
|
374
|
+
const sessionNonceDigest = digestArtifact(bootstrap.supervisor_session_nonce);
|
|
375
|
+
const stagedLeases = new Map();
|
|
376
|
+
const armedLeases = new Map();
|
|
377
|
+
const preparedPackets = new Map();
|
|
378
|
+
const tasks = new Map();
|
|
379
|
+
const todoToHandle = new Map();
|
|
380
|
+
let currentEpoch = 1;
|
|
381
|
+
let registrationDigest = null;
|
|
382
|
+
|
|
383
|
+
const persistReceipt = async (receipt) => {
|
|
384
|
+
const payloadDigest = digestArtifact(receipt);
|
|
385
|
+
const receiptPath = path.join(
|
|
386
|
+
canonicalRunDir,
|
|
387
|
+
'controllers',
|
|
388
|
+
controllerId,
|
|
389
|
+
'receipts',
|
|
390
|
+
`${payloadDigest}.json`,
|
|
391
|
+
);
|
|
392
|
+
await durableReplaceBytes(
|
|
393
|
+
receiptPath,
|
|
394
|
+
Buffer.from(`${canonicalizeArtifact(receipt)}\n`),
|
|
395
|
+
);
|
|
396
|
+
return payloadDigest;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const responseFor = async (operation, request) => {
|
|
400
|
+
if (!validateControllerRequest(operation, request)) {
|
|
401
|
+
fail('SCRIPTED_REQUEST_INVALID', `${operation} requestがprotocol contractを満たさない`);
|
|
402
|
+
}
|
|
403
|
+
if (registrationDigest !== null && request.registration_digest !== registrationDigest) {
|
|
404
|
+
fail('SCRIPTED_REGISTRATION_MISMATCH', 'controller registration digestが変化した');
|
|
405
|
+
}
|
|
406
|
+
registrationDigest = request.registration_digest;
|
|
407
|
+
if (operation === 'prepare') {
|
|
408
|
+
const packet = request.executor_packet;
|
|
409
|
+
const lease = request.staged_lease;
|
|
410
|
+
if (lease.run_id !== bootstrap.run_id || lease.todo_id !== packet.todo_id
|
|
411
|
+
|| lease.plan_epoch !== packet.plan_epoch || lease.packet_digest !== packet.packet_digest
|
|
412
|
+
|| lease.controller_registration_digest !== request.registration_digest
|
|
413
|
+
|| lease.supervisor_session_nonce_digest !== sessionNonceDigest) {
|
|
414
|
+
fail('SCRIPTED_LEASE_REJECTED', 'prepare packetとstaged leaseのbindingが不正');
|
|
415
|
+
}
|
|
416
|
+
const prior = stagedLeases.get(lease.lease_digest);
|
|
417
|
+
if (prior !== undefined
|
|
418
|
+
&& canonicalizeArtifact(prior) !== canonicalizeArtifact(lease)) {
|
|
419
|
+
fail('SCRIPTED_LEASE_REJECTED', '同じdigestのstaged lease bytesが変化した');
|
|
420
|
+
}
|
|
421
|
+
stagedLeases.set(lease.lease_digest, structuredClone(lease));
|
|
422
|
+
preparedPackets.set(packet.packet_digest, structuredClone(packet));
|
|
423
|
+
currentEpoch = packet.plan_epoch;
|
|
424
|
+
const ack = sign({
|
|
425
|
+
schema: 'lattice.adapter_prepare_ack.v1',
|
|
426
|
+
ack_id: `prepare-${packet.packet_digest.slice(0, 24)}`,
|
|
427
|
+
registration_digest: request.registration_digest,
|
|
428
|
+
controller_id: controllerId,
|
|
429
|
+
run_id: bootstrap.run_id,
|
|
430
|
+
todo_id: packet.todo_id,
|
|
431
|
+
plan_epoch: packet.plan_epoch,
|
|
432
|
+
packet_digest: packet.packet_digest,
|
|
433
|
+
staged_lease_digest: lease.lease_digest,
|
|
434
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
435
|
+
ack_digest: '',
|
|
436
|
+
}, 'ack_digest');
|
|
437
|
+
return sign({
|
|
438
|
+
schema: 'lattice.adapter_prepare_response.v1',
|
|
439
|
+
request_id: request.request_id,
|
|
440
|
+
prepare_ack: ack,
|
|
441
|
+
staged_lease_digest: lease.lease_digest,
|
|
442
|
+
response_digest: '',
|
|
443
|
+
}, 'response_digest');
|
|
444
|
+
}
|
|
445
|
+
if (operation === 'activate') {
|
|
446
|
+
if (request.staged_lease_digests.some((digest) => !stagedLeases.has(digest))) {
|
|
447
|
+
fail('SCRIPTED_LEASE_REJECTED', 'activateが未知のstaged leaseを含む');
|
|
448
|
+
}
|
|
449
|
+
const epochs = new Set(request.staged_lease_digests
|
|
450
|
+
.map((digest) => stagedLeases.get(digest).plan_epoch));
|
|
451
|
+
if (epochs.size > 1) fail('SCRIPTED_LEASE_REJECTED', 'activate leaseのepochが混在する');
|
|
452
|
+
if (epochs.size === 1) currentEpoch = [...epochs][0];
|
|
453
|
+
const ack = sign({
|
|
454
|
+
schema: 'lattice.adapter_ready_ack.v1',
|
|
455
|
+
ack_id: `ready-${request.activation_digest.slice(0, 24)}`,
|
|
456
|
+
registration_digest: request.registration_digest,
|
|
457
|
+
controller_id: controllerId,
|
|
458
|
+
run_id: bootstrap.run_id,
|
|
459
|
+
plan_epoch: currentEpoch,
|
|
460
|
+
activation_digest: request.activation_digest,
|
|
461
|
+
staged_lease_digests: [...request.staged_lease_digests],
|
|
462
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
463
|
+
ack_digest: '',
|
|
464
|
+
}, 'ack_digest');
|
|
465
|
+
return sign({
|
|
466
|
+
schema: 'lattice.adapter_activate_response.v1',
|
|
467
|
+
request_id: request.request_id,
|
|
468
|
+
ready_ack: ack,
|
|
469
|
+
observed_pointer_digest: request.committed_epoch_digest,
|
|
470
|
+
response_digest: '',
|
|
471
|
+
}, 'response_digest');
|
|
472
|
+
}
|
|
473
|
+
if (operation === 'release') {
|
|
474
|
+
const armed = [];
|
|
475
|
+
for (const stagedDigest of request.staged_lease_digests) {
|
|
476
|
+
const staged = stagedLeases.get(stagedDigest);
|
|
477
|
+
if (staged === undefined) {
|
|
478
|
+
fail('SCRIPTED_LEASE_REJECTED', 'releaseが未知のstaged leaseを含む');
|
|
479
|
+
}
|
|
480
|
+
const lease = armStagedWriteLease(staged, {
|
|
481
|
+
releaseBarrierDigest: request.release_barrier_digest,
|
|
482
|
+
gateGeneration: request.gate_generation,
|
|
483
|
+
});
|
|
484
|
+
stagedLeases.delete(stagedDigest);
|
|
485
|
+
armedLeases.set(lease.lease_digest, lease);
|
|
486
|
+
armed.push(lease.lease_digest);
|
|
487
|
+
}
|
|
488
|
+
armed.sort();
|
|
489
|
+
const ack = sign({
|
|
490
|
+
schema: 'lattice.adapter_release_ack.v1',
|
|
491
|
+
ack_id: `release-${request.release_barrier_digest.slice(0, 24)}`,
|
|
492
|
+
registration_digest: request.registration_digest,
|
|
493
|
+
controller_id: controllerId,
|
|
494
|
+
run_id: bootstrap.run_id,
|
|
495
|
+
plan_epoch: currentEpoch,
|
|
496
|
+
release_barrier_digest: request.release_barrier_digest,
|
|
497
|
+
gate_generation: request.gate_generation,
|
|
498
|
+
armed_lease_digests: armed,
|
|
499
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
500
|
+
ack_digest: '',
|
|
501
|
+
}, 'ack_digest');
|
|
502
|
+
return sign({
|
|
503
|
+
schema: 'lattice.adapter_release_response.v1',
|
|
504
|
+
request_id: request.request_id,
|
|
505
|
+
release_ack: ack,
|
|
506
|
+
armed_lease_digests: armed,
|
|
507
|
+
observed_gate_generation: request.gate_generation,
|
|
508
|
+
response_digest: '',
|
|
509
|
+
}, 'response_digest');
|
|
510
|
+
}
|
|
511
|
+
if (operation === 'dispatch') {
|
|
512
|
+
const { packet, write_lease: writeLease } = request;
|
|
513
|
+
if (!validateExecutorPacket(packet) || !validateArmedWriteLease(writeLease)
|
|
514
|
+
|| !armedLeases.has(writeLease.lease_digest)
|
|
515
|
+
|| writeLease.run_id !== bootstrap.run_id
|
|
516
|
+
|| writeLease.todo_id !== packet.todo_id
|
|
517
|
+
|| writeLease.plan_epoch !== packet.plan_epoch
|
|
518
|
+
|| writeLease.packet_digest !== packet.packet_digest
|
|
519
|
+
|| writeLease.controller_registration_digest !== request.registration_digest
|
|
520
|
+
|| writeLease.supervisor_session_nonce_digest !== sessionNonceDigest) {
|
|
521
|
+
fail('SCRIPTED_PACKET_REJECTED', 'dispatch packetとarmed leaseのbindingが不正');
|
|
522
|
+
}
|
|
523
|
+
const priorHandle = todoToHandle.get(packet.todo_id);
|
|
524
|
+
if (priorHandle !== undefined) {
|
|
525
|
+
const prior = tasks.get(priorHandle);
|
|
526
|
+
if (prior.packet.packet_digest !== packet.packet_digest
|
|
527
|
+
|| prior.lease.lease_digest !== writeLease.lease_digest) {
|
|
528
|
+
fail('SCRIPTED_DUPLICATE_DISPATCH', '同じTODOへ異なるpacketをdispatchできない');
|
|
529
|
+
}
|
|
530
|
+
return sign({
|
|
531
|
+
schema: 'lattice.adapter_dispatch_response.v1',
|
|
532
|
+
request_id: request.request_id,
|
|
533
|
+
executor_handle: prior.executorHandle,
|
|
534
|
+
worktree_id: prior.worktreeId,
|
|
535
|
+
packet_digest: packet.packet_digest,
|
|
536
|
+
lease_digest: writeLease.lease_digest,
|
|
537
|
+
response_digest: '',
|
|
538
|
+
}, 'response_digest');
|
|
539
|
+
}
|
|
540
|
+
await readAndValidateGate(canonicalRunDir, writeLease);
|
|
541
|
+
const { observedDiff, checkpointDigest } = await executePacket({
|
|
542
|
+
packet,
|
|
543
|
+
repoRoot: canonicalRepoRoot,
|
|
544
|
+
});
|
|
545
|
+
const executorHandle = `scripted-${packet.packet_digest.slice(0, 24)}`;
|
|
546
|
+
const worktreeId = `scripted-wt-${packet.packet_digest.slice(0, 24)}`;
|
|
547
|
+
const receipt = sign({
|
|
548
|
+
schema: 'lattice.executor_receipt.v1',
|
|
549
|
+
receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
|
|
550
|
+
executor_handle: executorHandle,
|
|
551
|
+
worktree_id: worktreeId,
|
|
552
|
+
base_sha: packet.base_sha,
|
|
553
|
+
plan_epoch: packet.plan_epoch,
|
|
554
|
+
packet_digest: packet.packet_digest,
|
|
555
|
+
todo_id: packet.todo_id,
|
|
556
|
+
checkpoint_digest: checkpointDigest,
|
|
557
|
+
observed_diff: observedDiff,
|
|
558
|
+
receipt_digest: '',
|
|
559
|
+
}, 'receipt_digest');
|
|
560
|
+
if (!validateExecutorReceipt(receipt)) {
|
|
561
|
+
fail('SCRIPTED_EXECUTION_FAILED', '生成receiptがexecutor contractを満たさない');
|
|
562
|
+
}
|
|
563
|
+
const payloadDigest = await persistReceipt(receipt);
|
|
564
|
+
const task = {
|
|
565
|
+
packet: structuredClone(packet),
|
|
566
|
+
lease: structuredClone(writeLease),
|
|
567
|
+
executorHandle,
|
|
568
|
+
worktreeId,
|
|
569
|
+
receipt,
|
|
570
|
+
payloadDigest,
|
|
571
|
+
state: 'terminal',
|
|
572
|
+
};
|
|
573
|
+
tasks.set(executorHandle, task);
|
|
574
|
+
todoToHandle.set(packet.todo_id, executorHandle);
|
|
575
|
+
return sign({
|
|
576
|
+
schema: 'lattice.adapter_dispatch_response.v1',
|
|
577
|
+
request_id: request.request_id,
|
|
578
|
+
executor_handle: executorHandle,
|
|
579
|
+
worktree_id: worktreeId,
|
|
580
|
+
packet_digest: packet.packet_digest,
|
|
581
|
+
lease_digest: writeLease.lease_digest,
|
|
582
|
+
response_digest: '',
|
|
583
|
+
}, 'response_digest');
|
|
584
|
+
}
|
|
585
|
+
if (operation === 'observe') {
|
|
586
|
+
const task = tasks.get(request.executor_handle);
|
|
587
|
+
if (task === undefined
|
|
588
|
+
|| task.packet.plan_epoch !== request.expected_epoch
|
|
589
|
+
|| task.lease.lease_digest !== request.expected_lease_digest) {
|
|
590
|
+
fail('SCRIPTED_OBSERVATION_REJECTED', 'observe bindingがdispatch記録と一致しない');
|
|
591
|
+
}
|
|
592
|
+
const observation = sign({
|
|
593
|
+
schema: 'lattice.adapter_observation.v1',
|
|
594
|
+
state: task.state,
|
|
595
|
+
executor_handle: task.executorHandle,
|
|
596
|
+
plan_epoch: task.packet.plan_epoch,
|
|
597
|
+
lease_digest: task.lease.lease_digest,
|
|
598
|
+
payload_digest: task.payloadDigest,
|
|
599
|
+
observation_digest: '',
|
|
600
|
+
}, 'observation_digest');
|
|
601
|
+
return sign({
|
|
602
|
+
schema: 'lattice.adapter_observe_response.v1',
|
|
603
|
+
request_id: request.request_id,
|
|
604
|
+
observation,
|
|
605
|
+
observation_digest: observation.observation_digest,
|
|
606
|
+
response_digest: '',
|
|
607
|
+
}, 'response_digest');
|
|
608
|
+
}
|
|
609
|
+
if (operation === 'inventory') {
|
|
610
|
+
const runningBindings = [...tasks.values()]
|
|
611
|
+
.filter((task) => task.state === 'running')
|
|
612
|
+
.map((task) => ({
|
|
613
|
+
todo_id: task.packet.todo_id,
|
|
614
|
+
executor_handle: task.executorHandle,
|
|
615
|
+
worktree_id: task.worktreeId,
|
|
616
|
+
plan_epoch: task.packet.plan_epoch,
|
|
617
|
+
packet_digest: task.packet.packet_digest,
|
|
618
|
+
write_lease_id: task.lease.lease_id,
|
|
619
|
+
controller_registration_digest: request.registration_digest,
|
|
620
|
+
}))
|
|
621
|
+
.sort((left, right) => left.todo_id.localeCompare(right.todo_id));
|
|
622
|
+
return sign({
|
|
623
|
+
schema: 'lattice.adapter_running_inventory_response.v1',
|
|
624
|
+
request_id: request.request_id,
|
|
625
|
+
running_bindings: runningBindings,
|
|
626
|
+
inventory_digest: digestArtifact(runningBindings),
|
|
627
|
+
response_digest: '',
|
|
628
|
+
}, 'response_digest');
|
|
629
|
+
}
|
|
630
|
+
if (operation === 'barrier') {
|
|
631
|
+
const acks = request.running_bindings.map((binding) => {
|
|
632
|
+
const task = tasks.get(binding.executor_handle);
|
|
633
|
+
if (task === undefined || task.packet.todo_id !== binding.todo_id) {
|
|
634
|
+
fail('SCRIPTED_BARRIER_REJECTED', 'barrierが未知のrunning bindingを含む');
|
|
635
|
+
}
|
|
636
|
+
task.state = 'held';
|
|
637
|
+
const processObservationDigest = digestArtifact({
|
|
638
|
+
schema: 'lattice.scripted_process_observation.v1',
|
|
639
|
+
executor_handle: task.executorHandle,
|
|
640
|
+
state: 'stopped',
|
|
641
|
+
});
|
|
642
|
+
const worktreeFingerprintDigest = digestArtifact({
|
|
643
|
+
schema: 'lattice.scripted_worktree_fingerprint.v1',
|
|
644
|
+
worktree_id: task.worktreeId,
|
|
645
|
+
checkpoint_digest: task.receipt.checkpoint_digest,
|
|
646
|
+
});
|
|
647
|
+
return sign({
|
|
648
|
+
schema: 'lattice.executor_quiescence_ack.v1',
|
|
649
|
+
ack_id: `barrier-${binding.packet_digest.slice(0, 24)}`,
|
|
650
|
+
run_id: bootstrap.run_id,
|
|
651
|
+
todo_id: binding.todo_id,
|
|
652
|
+
executor_handle: binding.executor_handle,
|
|
653
|
+
worktree_id: binding.worktree_id,
|
|
654
|
+
plan_epoch: binding.plan_epoch,
|
|
655
|
+
packet_digest: binding.packet_digest,
|
|
656
|
+
write_lease_id: binding.write_lease_id,
|
|
657
|
+
barrier_control_digest: request.barrier_control_digest,
|
|
658
|
+
final_checkpoint_digest: task.receipt.checkpoint_digest,
|
|
659
|
+
process_observation_digest: processObservationDigest,
|
|
660
|
+
worktree_fingerprint_digest: worktreeFingerprintDigest,
|
|
661
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
662
|
+
ack_digest: '',
|
|
663
|
+
}, 'ack_digest');
|
|
664
|
+
});
|
|
665
|
+
return sign({
|
|
666
|
+
schema: 'lattice.adapter_barrier_response.v1',
|
|
667
|
+
request_id: request.request_id,
|
|
668
|
+
barrier_id: request.barrier_id,
|
|
669
|
+
quiescence_acks: acks,
|
|
670
|
+
response_digest: '',
|
|
671
|
+
}, 'response_digest');
|
|
672
|
+
}
|
|
673
|
+
if (operation === 'rebind') {
|
|
674
|
+
const rebind = request.rebind_packet;
|
|
675
|
+
const task = tasks.get(rebind.executor_handle);
|
|
676
|
+
if (task === undefined || task.worktreeId !== rebind.worktree_id
|
|
677
|
+
|| task.packet.todo_id !== rebind.todo_id) {
|
|
678
|
+
fail('SCRIPTED_REBIND_REJECTED', 'rebind packetが既存taskへ帰属しない');
|
|
679
|
+
}
|
|
680
|
+
const staged = request.staged_lease;
|
|
681
|
+
if (staged.todo_id !== rebind.todo_id || staged.plan_epoch !== rebind.new_plan_epoch
|
|
682
|
+
|| staged.packet_digest !== rebind.packet_digest) {
|
|
683
|
+
fail('SCRIPTED_REBIND_REJECTED', 'rebind packetとstaged leaseが一致しない');
|
|
684
|
+
}
|
|
685
|
+
stagedLeases.set(staged.lease_digest, structuredClone(staged));
|
|
686
|
+
task.packet.plan_epoch = rebind.new_plan_epoch;
|
|
687
|
+
currentEpoch = rebind.new_plan_epoch;
|
|
688
|
+
const ack = sign({
|
|
689
|
+
schema: 'lattice.executor_epoch_rebind_ack.v1',
|
|
690
|
+
ack_id: `rebind-${rebind.packet_digest.slice(0, 24)}`,
|
|
691
|
+
run_id: bootstrap.run_id,
|
|
692
|
+
todo_id: rebind.todo_id,
|
|
693
|
+
executor_handle: rebind.executor_handle,
|
|
694
|
+
worktree_id: rebind.worktree_id,
|
|
695
|
+
predecessor_epoch: rebind.new_plan_epoch - 1,
|
|
696
|
+
successor_epoch: rebind.new_plan_epoch,
|
|
697
|
+
predecessor_packet_digest: task.receipt.packet_digest,
|
|
698
|
+
rebind_packet_digest: rebind.packet_digest,
|
|
699
|
+
new_write_lease_id: staged.lease_id,
|
|
700
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
701
|
+
ack_digest: '',
|
|
702
|
+
}, 'ack_digest');
|
|
703
|
+
return sign({
|
|
704
|
+
schema: 'lattice.adapter_rebind_response.v1',
|
|
705
|
+
request_id: request.request_id,
|
|
706
|
+
rebind_ack: ack,
|
|
707
|
+
staged_lease_digest: staged.lease_digest,
|
|
708
|
+
response_digest: '',
|
|
709
|
+
}, 'response_digest');
|
|
710
|
+
}
|
|
711
|
+
if (operation === 'revoke') {
|
|
712
|
+
const available = new Set([...stagedLeases.keys(), ...armedLeases.keys()]);
|
|
713
|
+
if (request.lease_digests.some((digest) => !available.has(digest))) {
|
|
714
|
+
fail('SCRIPTED_REVOKE_REJECTED', 'revokeが未知のleaseを含む');
|
|
715
|
+
}
|
|
716
|
+
for (const leaseDigest of request.lease_digests) {
|
|
717
|
+
stagedLeases.delete(leaseDigest);
|
|
718
|
+
armedLeases.delete(leaseDigest);
|
|
719
|
+
}
|
|
720
|
+
for (const task of tasks.values()) {
|
|
721
|
+
if (request.lease_digests.includes(task.lease.lease_digest)) task.state = 'held';
|
|
722
|
+
}
|
|
723
|
+
return sign({
|
|
724
|
+
schema: 'lattice.adapter_revoke_response.v1',
|
|
725
|
+
request_id: request.request_id,
|
|
726
|
+
revoked_lease_digests: [...request.lease_digests],
|
|
727
|
+
residual_processes: [],
|
|
728
|
+
response_digest: '',
|
|
729
|
+
}, 'response_digest');
|
|
730
|
+
}
|
|
731
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のcontroller operation: ${operation}`);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
return Object.freeze({
|
|
735
|
+
bootstrap: structuredClone(bootstrap),
|
|
736
|
+
controllerId,
|
|
737
|
+
controllerSessionNonce,
|
|
738
|
+
descriptor: structuredClone(descriptor),
|
|
739
|
+
heartbeat,
|
|
740
|
+
leaseSetDigest() {
|
|
741
|
+
return digestArtifact([...stagedLeases.keys(), ...armedLeases.keys()].sort());
|
|
742
|
+
},
|
|
743
|
+
handshake(request) {
|
|
744
|
+
if (!validateHandshakeRequest(request, bootstrap)) {
|
|
745
|
+
fail('SCRIPTED_HANDSHAKE_INVALID', 'handshake requestがbootstrapへbindしない');
|
|
746
|
+
}
|
|
747
|
+
return sign({
|
|
748
|
+
schema: 'lattice.adapter_controller_handshake_response.v1',
|
|
749
|
+
request_id: request.request_id,
|
|
750
|
+
run_id: bootstrap.run_id,
|
|
751
|
+
challenge_digest: digestArtifact(request.challenge),
|
|
752
|
+
controller_session_nonce: controllerSessionNonce,
|
|
753
|
+
descriptor,
|
|
754
|
+
response_digest: '',
|
|
755
|
+
}, 'response_digest');
|
|
756
|
+
},
|
|
757
|
+
async request(operation, request) {
|
|
758
|
+
if (!CONTROLLER_OPERATIONS.includes(operation)) {
|
|
759
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のcontroller operation: ${operation}`);
|
|
760
|
+
}
|
|
761
|
+
return responseFor(operation, request);
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function readBootstrapFd(fd = 3) {
|
|
767
|
+
const bytes = readFileSync(fd);
|
|
768
|
+
if (bytes.length === 0 || bytes.length > MAX_DOCUMENT_BYTES) {
|
|
769
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrap sizeが不正');
|
|
770
|
+
}
|
|
771
|
+
const text = bytes.toString('utf8');
|
|
772
|
+
if (!text.endsWith('\n') || text.indexOf('\n') !== text.length - 1) {
|
|
773
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrapはJSON 1 documentでなければならない');
|
|
774
|
+
}
|
|
775
|
+
let value;
|
|
776
|
+
try {
|
|
777
|
+
value = JSON.parse(text);
|
|
778
|
+
} catch {
|
|
779
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrap JSONが不正');
|
|
780
|
+
}
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function runScriptedAdapterController({
|
|
785
|
+
runDir = process.cwd(),
|
|
786
|
+
bootstrap = null,
|
|
787
|
+
stderr = process.stderr,
|
|
788
|
+
} = {}) {
|
|
789
|
+
const resolvedBootstrap = bootstrap ?? await readBootstrapFd();
|
|
790
|
+
const canonicalRunDir = await realpath(runDir);
|
|
791
|
+
const repoRoot = await realpath(path.resolve(canonicalRunDir, '..', '..', '..'));
|
|
792
|
+
const controller = await createScriptedAdapterController({
|
|
793
|
+
bootstrap: resolvedBootstrap,
|
|
794
|
+
runDir: canonicalRunDir,
|
|
795
|
+
repoRoot,
|
|
796
|
+
});
|
|
797
|
+
const socketPath = path.join(canonicalRunDir, resolvedBootstrap.controller_socket_ref);
|
|
798
|
+
const controllerDirectory = path.dirname(socketPath);
|
|
799
|
+
await mkdir(controllerDirectory, { recursive: true, mode: 0o700 });
|
|
800
|
+
await chmod(controllerDirectory, 0o700);
|
|
801
|
+
let heartbeatTimer = null;
|
|
802
|
+
let persistentSocket = null;
|
|
803
|
+
let heartbeatSequence = 0;
|
|
804
|
+
let registrationDigest = null;
|
|
805
|
+
const send = (socket, document) => {
|
|
806
|
+
socket.write(`${canonicalizeArtifact(document)}\n`);
|
|
807
|
+
};
|
|
808
|
+
const startHeartbeat = (socket, digest) => {
|
|
809
|
+
persistentSocket = socket;
|
|
810
|
+
registrationDigest = digest;
|
|
811
|
+
if (heartbeatTimer !== null) return;
|
|
812
|
+
heartbeatTimer = setInterval(() => {
|
|
813
|
+
if (socket.destroyed) return;
|
|
814
|
+
heartbeatSequence += 1;
|
|
815
|
+
send(socket, sign({
|
|
816
|
+
schema: 'lattice.adapter_controller_heartbeat.v1',
|
|
817
|
+
controller_id: controller.controllerId,
|
|
818
|
+
registration_digest: registrationDigest,
|
|
819
|
+
supervisor_session_nonce: resolvedBootstrap.supervisor_session_nonce,
|
|
820
|
+
sequence: heartbeatSequence,
|
|
821
|
+
lease_set_digest: controller.leaseSetDigest(),
|
|
822
|
+
heartbeat_digest: '',
|
|
823
|
+
}, 'heartbeat_digest'));
|
|
824
|
+
}, controller.heartbeat.interval_ms);
|
|
825
|
+
heartbeatTimer.unref();
|
|
826
|
+
};
|
|
827
|
+
const server = net.createServer((socket) => {
|
|
828
|
+
socket.setEncoding('utf8');
|
|
829
|
+
let buffer = '';
|
|
830
|
+
let processing = Promise.resolve();
|
|
831
|
+
socket.on('data', (chunk) => {
|
|
832
|
+
buffer += chunk;
|
|
833
|
+
if (Buffer.byteLength(buffer, 'utf8') > MAX_DOCUMENT_BYTES) {
|
|
834
|
+
send(socket, controllerErrorArtifact(
|
|
835
|
+
new ScriptedAdapterControllerError(
|
|
836
|
+
'SCRIPTED_DOCUMENT_TOO_LARGE',
|
|
837
|
+
'controller document上限を超えた',
|
|
838
|
+
),
|
|
839
|
+
));
|
|
840
|
+
socket.destroy();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
while (buffer.includes('\n')) {
|
|
844
|
+
const newline = buffer.indexOf('\n');
|
|
845
|
+
const line = buffer.slice(0, newline);
|
|
846
|
+
buffer = buffer.slice(newline + 1);
|
|
847
|
+
processing = processing.then(async () => {
|
|
848
|
+
let request;
|
|
849
|
+
try {
|
|
850
|
+
request = JSON.parse(line);
|
|
851
|
+
if (request.schema === 'lattice.adapter_controller_handshake_request.v1') {
|
|
852
|
+
send(socket, controller.handshake(request));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const operation = REQUEST_SCHEMA_TO_OPERATION[request.schema];
|
|
856
|
+
if (operation === undefined) {
|
|
857
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のrequest schema: ${String(request.schema)}`);
|
|
858
|
+
}
|
|
859
|
+
const response = await controller.request(operation, request);
|
|
860
|
+
startHeartbeat(socket, request.registration_digest);
|
|
861
|
+
send(socket, response);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
send(socket, controllerErrorArtifact(error, request?.request_id));
|
|
864
|
+
stderr.write(`${canonicalizeArtifact(controllerErrorArtifact(error, request?.request_id))}\n`);
|
|
865
|
+
socket.destroy();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
socket.on('close', () => {
|
|
871
|
+
if (socket === persistentSocket) {
|
|
872
|
+
clearInterval(heartbeatTimer);
|
|
873
|
+
heartbeatTimer = null;
|
|
874
|
+
persistentSocket = null;
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
server.on('error', (error) => {
|
|
879
|
+
stderr.write(`${canonicalizeArtifact(controllerErrorArtifact(error))}\n`);
|
|
880
|
+
});
|
|
881
|
+
await new Promise((resolve, reject) => {
|
|
882
|
+
server.once('error', reject);
|
|
883
|
+
// AF_UNIXのpath長上限へ一時repoのabsolute prefixを混入させない。supervisorも
|
|
884
|
+
// run storeをcwdにして同じ固定relative refへ接続する。
|
|
885
|
+
server.listen(resolvedBootstrap.controller_socket_ref, () => {
|
|
886
|
+
server.off('error', reject);
|
|
887
|
+
resolve();
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
await chmod(socketPath, 0o600);
|
|
891
|
+
let closed = false;
|
|
892
|
+
const close = async () => {
|
|
893
|
+
if (closed) return;
|
|
894
|
+
closed = true;
|
|
895
|
+
clearInterval(heartbeatTimer);
|
|
896
|
+
persistentSocket?.destroy();
|
|
897
|
+
await new Promise((resolve) => server.close(resolve));
|
|
898
|
+
await rm(socketPath, { force: true });
|
|
899
|
+
};
|
|
900
|
+
return Object.freeze({ controller, socketPath, close });
|
|
901
|
+
}
|