hoi4-agent-tools 3.0.9 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +4 -0
- package/dist/bin/stdio.js +23 -12
- package/dist/hoi4_agent_tools/core/analysis-checkpoints.js +82 -0
- package/dist/hoi4_agent_tools/core/artifact-resource.js +173 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +84 -2
- package/dist/hoi4_agent_tools/core/dependency-pages.js +216 -0
- package/dist/hoi4_agent_tools/core/dependency-walk.js +266 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +188 -0
- package/dist/hoi4_agent_tools/core/engine.js +48 -5
- package/dist/hoi4_agent_tools/core/helper-expansion.js +186 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +137 -0
- package/dist/hoi4_agent_tools/core/index.js +47 -4
- package/dist/hoi4_agent_tools/core/job-controls.js +109 -0
- package/dist/hoi4_agent_tools/core/job-executor.js +190 -0
- package/dist/hoi4_agent_tools/core/job-service.js +112 -0
- package/dist/hoi4_agent_tools/core/job-store.js +641 -0
- package/dist/hoi4_agent_tools/core/job-transactions.js +126 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +200 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +62 -0
- package/dist/hoi4_agent_tools/core/operation-context.js +31 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +412 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +382 -0
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +383 -0
- package/dist/hoi4_agent_tools/core/result.js +1 -0
- package/dist/hoi4_agent_tools/core/scanner.js +15 -75
- package/dist/hoi4_agent_tools/core/semantic-dependencies.js +233 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +43 -11
- package/dist/hoi4_agent_tools/core/source/cache.js +107 -0
- package/dist/hoi4_agent_tools/core/source/parser.js +11 -1
- package/dist/hoi4_agent_tools/core/transaction-execution.js +134 -0
- package/dist/hoi4_agent_tools/core/transactions.js +80 -27
- package/dist/hoi4_agent_tools/event/graph.js +214 -290
- package/dist/hoi4_agent_tools/event/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/event/job-operations.js +51 -0
- package/dist/hoi4_agent_tools/event/operations.js +179 -0
- package/dist/hoi4_agent_tools/event/service.js +67 -15
- package/dist/hoi4_agent_tools/event/source-analysis.js +5 -4
- package/dist/hoi4_agent_tools/focus/job-operations.js +77 -0
- package/dist/hoi4_agent_tools/focus/tool-operations.js +669 -0
- package/dist/hoi4_agent_tools/focus/tool-support.js +123 -0
- package/dist/hoi4_agent_tools/gui/job-operations.js +81 -0
- package/dist/hoi4_agent_tools/gui/tool-operations.js +270 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +76 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +493 -0
- package/dist/hoi4_agent_tools/mcp/prompts/probability.js +25 -0
- package/dist/hoi4_agent_tools/mcp/prompts/register-modern.js +29 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +34 -0
- package/dist/hoi4_agent_tools/mcp/resources/register.js +4 -150
- package/dist/hoi4_agent_tools/mcp/server/base-tools.js +3 -219
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +188 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +16 -6
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +67 -0
- package/dist/hoi4_agent_tools/mcp/server/legacy-task-tools.js +20 -0
- package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +168 -0
- package/dist/hoi4_agent_tools/mcp/server/progress.js +17 -15
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +43 -0
- package/dist/hoi4_agent_tools/mcp/server/result.js +2 -412
- package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +9 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +14 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-definition.js +1 -0
- package/dist/hoi4_agent_tools/mcp/server/transaction-execution.js +1 -123
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +38 -21
- package/dist/hoi4_agent_tools/mcp/tools/event.js +24 -315
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +34 -1039
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +19 -504
- package/dist/hoi4_agent_tools/mcp/tools/job.js +46 -0
- package/dist/hoi4_agent_tools/mcp/tools/map.js +21 -600
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +39 -463
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +40 -304
- package/dist/hoi4_agent_tools/mcp/transports/http.js +58 -1
- package/dist/hoi4_agent_tools/mcp/transports/modern-task-routing.js +109 -0
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +121 -0
- package/dist/hoi4_agent_tools/probability/job-operations.js +58 -0
- package/dist/hoi4_agent_tools/probability/operations.js +183 -0
- package/dist/hoi4_agent_tools/probability/service.js +24 -3
- package/dist/hoi4_agent_tools/schemas/common.js +2 -0
- package/dist/hoi4_agent_tools/schemas/event.js +72 -1
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +189 -0
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +193 -0
- package/dist/hoi4_agent_tools/schemas/helper-expansion.js +58 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +85 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +168 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +90 -1
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -0
- package/dist/hoi4_agent_tools/technology/graph.js +83 -78
- package/dist/hoi4_agent_tools/technology/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/technology/job-operations.js +60 -0
- package/dist/hoi4_agent_tools/technology/operations.js +125 -0
- package/dist/hoi4_agent_tools/technology/service.js +89 -15
- package/dist/hoi4_agent_tools/technology/source-analysis.js +2 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +2 -0
- package/docs/development.md +2 -0
- package/docs/events.md +7 -2
- package/docs/helper-expansion.md +78 -0
- package/docs/http.md +2 -0
- package/docs/jobs.md +85 -0
- package/docs/technology.md +6 -2
- package/package.json +11 -5
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +44 -0
- package/schemas/helper-expansion-summary.schema.json +70 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { ServiceError } from './result.js';
|
|
3
|
+
import { executePlannedTransaction, } from './transaction-execution.js';
|
|
4
|
+
const jsonObject = z.record(z.string(), z.json());
|
|
5
|
+
const defaultCompletion = (execution, reconciled) => Promise.resolve(jsonObject.parse({
|
|
6
|
+
execution: execution.outcome,
|
|
7
|
+
reconciled,
|
|
8
|
+
validationPassed: execution.transaction.validation.passed,
|
|
9
|
+
artifacts: [
|
|
10
|
+
...execution.artifacts,
|
|
11
|
+
...(execution.transaction.executionArtifacts ?? []),
|
|
12
|
+
...execution.transaction.artifacts,
|
|
13
|
+
],
|
|
14
|
+
changedFiles: execution.outcome === 'applied'
|
|
15
|
+
? execution.transaction.files.map(({ relativePath }) => relativePath)
|
|
16
|
+
: [],
|
|
17
|
+
}));
|
|
18
|
+
/** One shared write boundary for background execution and interrupted-outcome reconciliation. */
|
|
19
|
+
export class JobTransactions {
|
|
20
|
+
engine;
|
|
21
|
+
jobs;
|
|
22
|
+
constructor(engine, jobs) {
|
|
23
|
+
this.engine = engine;
|
|
24
|
+
this.jobs = jobs;
|
|
25
|
+
}
|
|
26
|
+
async execute(workspaceId, id, ownerToken, transaction, principal, signal, recipe, completion = defaultCompletion) {
|
|
27
|
+
const job = await this.owned(workspaceId, id, ownerToken, principal, signal);
|
|
28
|
+
this.assertTransaction(job, transaction);
|
|
29
|
+
if (job.transaction !== undefined) {
|
|
30
|
+
if (job.transaction.transactionId !== transaction.transactionId ||
|
|
31
|
+
job.transaction.planHash !== transaction.planHash)
|
|
32
|
+
throw new ServiceError('JOB_TRANSACTION_CONFLICT', 'The retry does not match the job transaction binding');
|
|
33
|
+
return this.reconcile(workspaceId, id, ownerToken, principal, signal, true, completion);
|
|
34
|
+
}
|
|
35
|
+
return this.apply(job, ownerToken, transaction, principal, signal, recipe, completion);
|
|
36
|
+
}
|
|
37
|
+
async reconcile(workspaceId, id, ownerToken, principal, signal, resumePlanned = true, completion = defaultCompletion) {
|
|
38
|
+
const job = await this.owned(workspaceId, id, ownerToken, principal, signal);
|
|
39
|
+
if (job.transaction === undefined)
|
|
40
|
+
throw new ServiceError('JOB_TRANSACTION_UNBOUND', 'No persisted transaction binding exists for this job');
|
|
41
|
+
const transaction = await this.engine.transactions.recoverTransaction(workspaceId, job.transaction.transactionId, principal, signal);
|
|
42
|
+
this.assertTransaction(job, transaction);
|
|
43
|
+
if (transaction.planHash !== job.transaction.planHash)
|
|
44
|
+
throw new ServiceError('JOB_TRANSACTION_CONFLICT', 'The recorded transaction plan does not match the job binding');
|
|
45
|
+
if (transaction.state === 'applied')
|
|
46
|
+
return this.complete(job, ownerToken, { transaction, outcome: 'applied', artifacts: [] }, true, principal, job.writeRecipe, completion);
|
|
47
|
+
if (transaction.state === 'planned') {
|
|
48
|
+
if (resumePlanned)
|
|
49
|
+
return this.apply(job, ownerToken, transaction, principal, signal, job.writeRecipe, completion);
|
|
50
|
+
if (job.cancelRequested)
|
|
51
|
+
return this.jobs.store.updateOwned(this.jobs.scope(workspaceId, principal), id, ownerToken, { status: 'cancelled', cancelRequested: true });
|
|
52
|
+
return this.fail(job, ownerToken, 'JOB_REWRITE_NOT_APPLIED', 'Execution stopped before source application; the plan was not replayed', principal);
|
|
53
|
+
}
|
|
54
|
+
if (transaction.state === 'rolled_back' || transaction.state === 'failed')
|
|
55
|
+
return this.fail(job, ownerToken, transaction.rollbackStatus === 'applied'
|
|
56
|
+
? 'JOB_REWRITE_RESTORED'
|
|
57
|
+
: 'JOB_REWRITE_RECOVERY_REQUIRED', transaction.rollbackStatus === 'applied'
|
|
58
|
+
? 'The interrupted rewrite was restored; it was not replayed'
|
|
59
|
+
: 'The transaction could not establish a completed or restored outcome', principal);
|
|
60
|
+
throw new ServiceError('JOB_TRANSACTION_UNRESOLVED', 'The transaction has no stable outcome after recovery');
|
|
61
|
+
}
|
|
62
|
+
async owned(workspaceId, id, ownerToken, principal, signal) {
|
|
63
|
+
const job = await this.jobs.get(workspaceId, id, principal, signal);
|
|
64
|
+
if (job.owner?.token !== ownerToken)
|
|
65
|
+
throw new ServiceError('JOB_OWNER_CHANGED', 'The job is owned by another execution attempt');
|
|
66
|
+
if (!job.request.mutation || !['running', 'reconciling'].includes(job.status))
|
|
67
|
+
throw new ServiceError('JOB_STATE_CONFLICT', 'An active rewrite job is required');
|
|
68
|
+
return job;
|
|
69
|
+
}
|
|
70
|
+
assertTransaction(job, transaction) {
|
|
71
|
+
if (transaction.workspaceId !== job.scope.workspaceId ||
|
|
72
|
+
(transaction.principal ?? null) !== job.scope.principal ||
|
|
73
|
+
transaction.rootFingerprint !== job.scope.rootFingerprint)
|
|
74
|
+
throw new ServiceError('JOB_TRANSACTION_SCOPE_MISMATCH', 'The transaction does not belong to the authorized job scope');
|
|
75
|
+
}
|
|
76
|
+
async apply(job, ownerToken, transaction, principal, signal, recipe, completion = defaultCompletion) {
|
|
77
|
+
try {
|
|
78
|
+
const execution = await executePlannedTransaction(this.engine, transaction, principal, signal, {
|
|
79
|
+
beforeApply: async (planned) => {
|
|
80
|
+
const bound = await this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, {
|
|
81
|
+
transaction: { transactionId: planned.transactionId, planHash: planned.planHash },
|
|
82
|
+
...(recipe === undefined ? {} : { writeRecipe: recipe }),
|
|
83
|
+
}, signal);
|
|
84
|
+
if (bound.cancelRequested)
|
|
85
|
+
throw new DOMException('Job cancellation was requested before application', 'AbortError');
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return await this.complete(job, ownerToken, execution, false, principal, recipe, completion);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Publication of the job result can fail after the source commit. Consult the journal,
|
|
92
|
+
// never replay the write merely because the caller did not receive its result.
|
|
93
|
+
const current = await this.jobs.get(job.scope.workspaceId, job.id, principal);
|
|
94
|
+
if (current.status === 'completed')
|
|
95
|
+
return current;
|
|
96
|
+
if (current.owner?.token !== ownerToken)
|
|
97
|
+
throw new ServiceError('JOB_OWNER_CHANGED', 'The job is owned by another execution attempt');
|
|
98
|
+
if (current.transaction !== undefined) {
|
|
99
|
+
const outcome = await this.engine.transactions.recoverTransaction(job.scope.workspaceId, current.transaction.transactionId, principal);
|
|
100
|
+
this.assertTransaction(current, outcome);
|
|
101
|
+
if (outcome.state === 'applied')
|
|
102
|
+
return this.complete(current, ownerToken, { transaction: outcome, outcome: 'applied', artifacts: [] }, true, principal, current.writeRecipe ?? recipe, completion);
|
|
103
|
+
if (outcome.state === 'rolled_back')
|
|
104
|
+
return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', 'The rewrite failed and its original source was restored', principal);
|
|
105
|
+
if (outcome.state === 'failed')
|
|
106
|
+
return this.fail(current, ownerToken, 'JOB_REWRITE_RECOVERY_REQUIRED', 'The transaction could not establish a completed or restored outcome', principal);
|
|
107
|
+
}
|
|
108
|
+
if (error.name === 'AbortError') {
|
|
109
|
+
return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, { status: 'cancelled', cancelRequested: true });
|
|
110
|
+
}
|
|
111
|
+
return this.fail(current, ownerToken, error instanceof ServiceError ? error.code : 'JOB_REWRITE_FAILED', error instanceof ServiceError
|
|
112
|
+
? error.message
|
|
113
|
+
: 'The rewrite failed before a committed outcome could be recorded', principal);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async complete(job, ownerToken, execution, reconciled, principal, recipe, completion = defaultCompletion) {
|
|
117
|
+
const result = await completion(execution, reconciled, recipe);
|
|
118
|
+
return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, {
|
|
119
|
+
status: 'completed',
|
|
120
|
+
result,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
fail(job, ownerToken, code, message, principal) {
|
|
124
|
+
return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, { status: 'failed', failure: { code, message } });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { JobService } from './job-service.js';
|
|
6
|
+
import { jobOwnerLiveness, readCheckpointIdentity } from './job-store.js';
|
|
7
|
+
import { RequestScheduler } from './request-scheduler.js';
|
|
8
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
9
|
+
import { containedGeneratedPath } from './workspace.js';
|
|
10
|
+
import { ServiceError } from './result.js';
|
|
11
|
+
const maximumCheckpointRecoveries = 2;
|
|
12
|
+
const maximumPreDispatchRecoveries = 2;
|
|
13
|
+
/** Bounded, fixed-entry child execution; client cancellation/disconnection never kills a worker. */
|
|
14
|
+
export class JobWorkerHost {
|
|
15
|
+
engine;
|
|
16
|
+
jobs;
|
|
17
|
+
capacity;
|
|
18
|
+
scheduler;
|
|
19
|
+
owner = {};
|
|
20
|
+
constructor(engine, jobs, capacity, scheduler) {
|
|
21
|
+
this.engine = engine;
|
|
22
|
+
this.jobs = jobs;
|
|
23
|
+
this.capacity = capacity;
|
|
24
|
+
this.scheduler = scheduler;
|
|
25
|
+
}
|
|
26
|
+
static async create(engine, jobs) {
|
|
27
|
+
const state = engine.resolver.serverState();
|
|
28
|
+
if (state === undefined)
|
|
29
|
+
throw new ServiceError('JOB_STORAGE_UNAVAILABLE', 'Workers require persistent server state');
|
|
30
|
+
const root = await containedGeneratedPath(state.root, 'job-workers');
|
|
31
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
32
|
+
const config = engine.resolver.config();
|
|
33
|
+
return new JobWorkerHost(engine, jobs ?? (await JobService.create(engine.resolver)), new SharedRequestCapacity(root, config.maxSharedTools), new RequestScheduler(config.maxConcurrentTools));
|
|
34
|
+
}
|
|
35
|
+
async run(workspaceId, id, principal) {
|
|
36
|
+
const initial = await this.jobs.get(workspaceId, id, principal);
|
|
37
|
+
if (['completed', 'cancelled', 'failed'].includes(initial.status))
|
|
38
|
+
return initial;
|
|
39
|
+
if (![
|
|
40
|
+
'hoi4.event_inspect',
|
|
41
|
+
'hoi4.event_render',
|
|
42
|
+
'hoi4.event_compare',
|
|
43
|
+
'hoi4.tech_inspect',
|
|
44
|
+
'hoi4.tech_render',
|
|
45
|
+
'hoi4.tech_compare',
|
|
46
|
+
'hoi4.probability_inspect',
|
|
47
|
+
'hoi4.probability_evaluate',
|
|
48
|
+
'hoi4.probability_sweep',
|
|
49
|
+
'hoi4.probability_simulate',
|
|
50
|
+
'hoi4.probability_sequence',
|
|
51
|
+
'hoi4.probability_compare',
|
|
52
|
+
'hoi4.probability_render',
|
|
53
|
+
'hoi4.map_inspect',
|
|
54
|
+
'hoi4.map_render',
|
|
55
|
+
'hoi4.map_rewrite',
|
|
56
|
+
'hoi4.gui_inspect',
|
|
57
|
+
'hoi4.gui_render',
|
|
58
|
+
'hoi4.gui_rewrite',
|
|
59
|
+
'hoi4.focus_inspect',
|
|
60
|
+
'hoi4.focus_render',
|
|
61
|
+
'hoi4.focus_raster',
|
|
62
|
+
'hoi4.focus_rewrite',
|
|
63
|
+
].includes(initial.request.toolName))
|
|
64
|
+
throw new ServiceError('JOB_OPERATION_UNAVAILABLE', 'This domain has not yet been registered for worker execution');
|
|
65
|
+
const signal = new AbortController().signal;
|
|
66
|
+
const recovered = new Set();
|
|
67
|
+
let preDispatchRecoveries = 0;
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
await this.scheduler.run(this.owner, 1024, signal, () => this.capacity.run(signal, (lease) => this.launch(workspaceId, id, lease, principal)));
|
|
71
|
+
return await this.jobs.get(workspaceId, id, principal);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const current = await this.jobs.get(workspaceId, id, principal);
|
|
75
|
+
if (error instanceof ServiceError &&
|
|
76
|
+
error.code === 'REQUEST_LEASE_HANDOFF_LOST' &&
|
|
77
|
+
(current.status === 'queued' ||
|
|
78
|
+
(current.owner !== undefined && jobOwnerLiveness(current.owner) === 'dead')) &&
|
|
79
|
+
preDispatchRecoveries < maximumPreDispatchRecoveries) {
|
|
80
|
+
// No job request was sent to the child: another bounded admission attempt
|
|
81
|
+
// cannot replay a read or a rewrite.
|
|
82
|
+
preDispatchRecoveries += 1;
|
|
83
|
+
await delay(preDispatchRecoveries * 100);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const checkpoint = readCheckpointIdentity(current);
|
|
87
|
+
if (checkpoint !== undefined &&
|
|
88
|
+
current.owner !== undefined &&
|
|
89
|
+
jobOwnerLiveness(current.owner) === 'dead' &&
|
|
90
|
+
recovered.size < maximumCheckpointRecoveries &&
|
|
91
|
+
!recovered.has(checkpoint)) {
|
|
92
|
+
// A replacement revalidates source identity and checkpoint bytes in the normal
|
|
93
|
+
// executor. Never replay writes, repeat a stalled frontier, or reclaim a live owner.
|
|
94
|
+
recovered.add(checkpoint);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
return await this.jobs.failInterrupted(workspaceId, id, {
|
|
98
|
+
code: error instanceof ServiceError ? error.code : 'JOB_WORKER_FAILED',
|
|
99
|
+
message: error instanceof ServiceError
|
|
100
|
+
? error.message
|
|
101
|
+
: 'The isolated worker stopped without publishing a result',
|
|
102
|
+
}, principal);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async launch(workspaceId, id, lease, principal) {
|
|
107
|
+
// Resolve the same configured workspace and grants again immediately before dispatch.
|
|
108
|
+
await this.jobs.get(workspaceId, id, principal);
|
|
109
|
+
const sourceMode = import.meta.url.endsWith('.ts');
|
|
110
|
+
const entry = fileURLToPath(new URL(sourceMode ? './job-worker.ts' : './job-worker.js', import.meta.url));
|
|
111
|
+
const configuration = this.engine.resolver.config();
|
|
112
|
+
const registeredWorkspaceIds = new Set(configuration.workspaces.map((workspace) => workspace.id));
|
|
113
|
+
const resolvedWorkspaces = this.engine.resolver.list();
|
|
114
|
+
const discoveredWorkspaceIds = resolvedWorkspaces
|
|
115
|
+
.map((workspace) => workspace.id)
|
|
116
|
+
.filter((workspaceId) => !registeredWorkspaceIds.has(workspaceId));
|
|
117
|
+
const preserveDiscoveredGrants = (grant) => grant.allowDiscoveredMods
|
|
118
|
+
? {
|
|
119
|
+
...grant,
|
|
120
|
+
workspaceIds: [...new Set([...grant.workspaceIds, ...discoveredWorkspaceIds])],
|
|
121
|
+
}
|
|
122
|
+
: grant;
|
|
123
|
+
const child = spawn(process.execPath, [...(sourceMode ? ['--import', import.meta.resolve('tsx')] : []), entry], {
|
|
124
|
+
detached: true,
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
});
|
|
129
|
+
const initialized = new Promise((resolve, reject) => {
|
|
130
|
+
child.once('error', reject);
|
|
131
|
+
child.once('exit', () => reject(new ServiceError('JOB_WORKER_STARTUP_EXIT', 'The worker exited before completing its readiness handshake')));
|
|
132
|
+
child.once('message', (value) => {
|
|
133
|
+
void (async () => {
|
|
134
|
+
if (typeof value !== 'object' ||
|
|
135
|
+
value === null ||
|
|
136
|
+
!('type' in value) ||
|
|
137
|
+
value.type !== 'ready' ||
|
|
138
|
+
child.pid === undefined)
|
|
139
|
+
throw new ServiceError('JOB_WORKER_PROTOCOL', 'The worker did not provide its readiness handshake');
|
|
140
|
+
await lease.handoffToProcess(child.pid);
|
|
141
|
+
await new Promise((resolve, reject) => {
|
|
142
|
+
child.once('disconnect', resolve);
|
|
143
|
+
child.send({
|
|
144
|
+
configuration: {
|
|
145
|
+
...configuration,
|
|
146
|
+
modRoots: [],
|
|
147
|
+
workspaces: resolvedWorkspaces.map(({ registration }) => registration),
|
|
148
|
+
http: {
|
|
149
|
+
...configuration.http,
|
|
150
|
+
tokens: configuration.http.tokens.map(preserveDiscoveredGrants),
|
|
151
|
+
principals: configuration.http.principals.map(preserveDiscoveredGrants),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
workspaceId,
|
|
155
|
+
jobId: id,
|
|
156
|
+
...(principal === undefined ? {} : { principal }),
|
|
157
|
+
}, (error) => {
|
|
158
|
+
if (error === null) {
|
|
159
|
+
// IPC is startup-only. Closing it after dispatch prevents launcher
|
|
160
|
+
// lifetime from becoming an implicit cancellation channel.
|
|
161
|
+
if (child.connected)
|
|
162
|
+
child.disconnect();
|
|
163
|
+
resolve();
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
reject(error);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
resolve();
|
|
170
|
+
})().catch((error) => {
|
|
171
|
+
if (child.connected)
|
|
172
|
+
child.disconnect();
|
|
173
|
+
reject(error instanceof Error
|
|
174
|
+
? error
|
|
175
|
+
: new ServiceError('JOB_WORKER_PROTOCOL', 'Worker initialization failed'));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
await initialized;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const record = await this.jobs.get(workspaceId, id, principal);
|
|
182
|
+
if (['completed', 'failed', 'cancelled'].includes(record.status))
|
|
183
|
+
return;
|
|
184
|
+
if (record.owner !== undefined && record.owner.pid !== child.pid) {
|
|
185
|
+
if (jobOwnerLiveness(record.owner) === 'alive')
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (child.pid !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(child.pid, 0);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error.code === 'ESRCH')
|
|
194
|
+
throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await delay(100);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { serverConfigurationSchema } from './configuration.js';
|
|
3
|
+
import { CoreEngine } from './engine.js';
|
|
4
|
+
import { JobExecutor, JobOperations } from './job-executor.js';
|
|
5
|
+
import { JobService } from './job-service.js';
|
|
6
|
+
import { WorkspaceResolver } from './workspace.js';
|
|
7
|
+
import { registerEventJobs } from '../event/job-operations.js';
|
|
8
|
+
import { registerFocusJobs } from '../focus/job-operations.js';
|
|
9
|
+
import { registerGuiJobs } from '../gui/job-operations.js';
|
|
10
|
+
import { registerMapJobs } from '../map/job-operations.js';
|
|
11
|
+
import { registerProbabilityJobs } from '../probability/job-operations.js';
|
|
12
|
+
import { registerTechnologyJobs } from '../technology/job-operations.js';
|
|
13
|
+
// Fixed internal process entry point. It accepts only a trusted host's structured IPC
|
|
14
|
+
// message, never a script path, module name, callback, command, or transport request.
|
|
15
|
+
const inputSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
configuration: serverConfigurationSchema,
|
|
18
|
+
workspaceId: z.string().min(1),
|
|
19
|
+
jobId: z.string().regex(/^job_[a-f0-9]{64}$/u),
|
|
20
|
+
principal: z.string().min(1).optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
if (process.send === undefined)
|
|
24
|
+
throw new Error('Job workers require their internal IPC host');
|
|
25
|
+
let started = false;
|
|
26
|
+
const startup = setTimeout(() => {
|
|
27
|
+
if (!started)
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}, 30_000);
|
|
30
|
+
startup.unref();
|
|
31
|
+
process.once('disconnect', () => {
|
|
32
|
+
if (!started)
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
|
35
|
+
process.once('message', (value) => {
|
|
36
|
+
started = true;
|
|
37
|
+
clearTimeout(startup);
|
|
38
|
+
void (async () => {
|
|
39
|
+
const input = inputSchema.parse(value);
|
|
40
|
+
const resolver = await WorkspaceResolver.create(input.configuration);
|
|
41
|
+
const engine = new CoreEngine(resolver);
|
|
42
|
+
// Recovery is targeted by the job executor. Do not sweep or interfere with
|
|
43
|
+
// unrelated live rewrite journals when a worker starts.
|
|
44
|
+
const jobs = await JobService.create(resolver);
|
|
45
|
+
const operations = new JobOperations();
|
|
46
|
+
registerEventJobs(operations, engine);
|
|
47
|
+
registerTechnologyJobs(operations, engine);
|
|
48
|
+
registerProbabilityJobs(operations, engine);
|
|
49
|
+
registerMapJobs(operations, engine);
|
|
50
|
+
registerGuiJobs(operations, engine);
|
|
51
|
+
registerFocusJobs(operations, engine);
|
|
52
|
+
await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
|
|
53
|
+
})()
|
|
54
|
+
.catch(() => {
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
})
|
|
57
|
+
.finally(() => {
|
|
58
|
+
if (process.connected)
|
|
59
|
+
process.disconnect();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
process.send({ type: 'ready' });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { ServiceError } from './result.js';
|
|
3
|
+
export async function resolveOperationWorkspaceId(engine, context, workspaceId, signal) {
|
|
4
|
+
if (workspaceId === 'current' && context.resolveCurrentWorkspaceId !== undefined) {
|
|
5
|
+
return context.resolveCurrentWorkspaceId(signal);
|
|
6
|
+
}
|
|
7
|
+
return engine.resolver.resolveWorkspaceId(workspaceId, context.principal);
|
|
8
|
+
}
|
|
9
|
+
export async function resolveOperationWorkspaceForSource(engine, context, workspaceId, relativePath, signal) {
|
|
10
|
+
const resolvedWorkspaceId = await resolveOperationWorkspaceId(engine, context, workspaceId, signal);
|
|
11
|
+
if (workspaceId !== 'current' || relativePath === undefined)
|
|
12
|
+
return resolvedWorkspaceId;
|
|
13
|
+
try {
|
|
14
|
+
const resolved = await engine.resolver.resolvePath(resolvedWorkspaceId, relativePath, 'read', ['mod'], context.principal);
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if ((await lstat(resolved.path)).isFile())
|
|
17
|
+
return resolvedWorkspaceId;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.name === 'AbortError')
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
return ((await engine.resolver.resolveUniqueModSourceWorkspaceId(relativePath, context.principal, signal)) ?? resolvedWorkspaceId);
|
|
24
|
+
}
|
|
25
|
+
export function requireOperationScope(context, scope) {
|
|
26
|
+
if (context.scopes !== undefined && !context.scopes.includes(scope)) {
|
|
27
|
+
throw new ServiceError('AUTH_SCOPE_REQUIRED', `This operation requires the ${scope} scope`, {
|
|
28
|
+
requiredScope: scope,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|