@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
+ }