@tsplc/build 0.2.8 → 0.2.10

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.
@@ -4,10 +4,13 @@ import { resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import type { ResolvedConfig } from "./config";
6
6
  import {
7
+ type ControllerCredentials,
7
8
  controllerResultSchema,
8
9
  controllerTarget,
9
10
  explicitDownload,
11
+ failureReason,
10
12
  loginCommandSchema,
13
+ warmOperationSchema,
11
14
  } from "./controller-online";
12
15
  import {
13
16
  createCodesysWorker,
@@ -90,7 +93,33 @@ const readBaseline = async (
90
93
  return { baseline, project };
91
94
  };
92
95
 
93
- /** Called inside the builder container while its output-directory flock is held. */
96
+ /** A CODESYS worker that handles the `online` request written to `requestDirectory` when `run`. */
97
+ interface OnlineWorker {
98
+ requestDirectory: string;
99
+ run: () => Promise<unknown>;
100
+ /** A candidate generation copied from baseline generation `baseline`, which the worker has open. */
101
+ prepared?: { baseline: string; generation: string };
102
+ }
103
+
104
+ /** A worker started for one operation and stopped after it. */
105
+ const oneShotWorker = (config: ResolvedConfig): OnlineWorker => {
106
+ const worker = createCodesysWorker(config);
107
+ return {
108
+ requestDirectory: resolve(config.outDir, ".codesys/controller"),
109
+ run: () =>
110
+ withWorkerShutdown(config, worker, () =>
111
+ withWorkerLock(config, async () =>
112
+ worker.request(await worker.launch(), { action: "online" }, config.timeouts.build),
113
+ ),
114
+ ),
115
+ };
116
+ };
117
+
118
+ /**
119
+ * Called inside the builder container, while its output-directory flock is held or, for a warm
120
+ * `worker`, while the agent runs nothing else. Without `credentials`, CODESYS reads the controller
121
+ * user from the environment variables `config.online` names.
122
+ */
94
123
  export const runControllerSession = async (
95
124
  config: ResolvedConfig,
96
125
  operation: "download" | "online-change" | "inspect",
@@ -99,7 +128,9 @@ export const runControllerSession = async (
99
128
  operationId: string;
100
129
  initializeUser?: boolean;
101
130
  read?: string[];
131
+ credentials?: ControllerCredentials;
102
132
  },
133
+ worker?: OnlineWorker,
103
134
  ) => {
104
135
  if (!config.online) {
105
136
  throw new Error("Controller operations require online settings");
@@ -119,12 +150,15 @@ export const runControllerSession = async (
119
150
  if (options.initializeUser && operation !== "download") {
120
151
  throw new Error("Initial user creation requires an explicit download");
121
152
  }
122
- const generation = randomUUID();
153
+ const opened = worker?.prepared;
154
+ const prepared =
155
+ operation === "online-change" && opened?.baseline === baseline?.generation ? opened : undefined;
156
+ const generation = prepared?.generation ?? randomUUID();
123
157
  const candidate = resolve(root, "generations", generation);
124
158
  const project = resolve(candidate, "project", "Application.project");
125
159
  await mkdir(resolve(candidate, "project"), { recursive: true });
126
160
  // A download saves the template here, with its controller and device tree.
127
- if (previous) {
161
+ if (previous && !prepared) {
128
162
  await cp(previous.project, resolve(candidate, "project"), {
129
163
  recursive: true,
130
164
  });
@@ -133,8 +167,19 @@ export const runControllerSession = async (
133
167
  ...config,
134
168
  outDir: resolve(root, "jobs", options.operationId),
135
169
  };
136
- const requestDirectory = resolve(jobConfig.outDir, ".codesys/controller");
170
+ // The job directory keeps this operation's progress and descriptions; the worker its mailbox.
171
+ const jobDirectory = resolve(jobConfig.outDir, ".codesys/controller");
172
+ const online = worker ?? oneShotWorker(jobConfig);
173
+ const { requestDirectory } = online;
174
+ await mkdir(jobDirectory, { recursive: true });
137
175
  await mkdir(requestDirectory, { recursive: true });
176
+ await rm(resolve(requestDirectory, "result.json"), { force: true });
177
+ // Only this container reads it; the worker deletes it once CODESYS has the user.
178
+ const credentialsFile =
179
+ options.credentials && `/tmp/tsplc-credentials-${options.operationId}.json`;
180
+ if (credentialsFile) {
181
+ await writeFile(credentialsFile, JSON.stringify(options.credentials), { mode: 0o600 });
182
+ }
138
183
  const sources = operation === "inspect" ? undefined : await loadSources(config);
139
184
  const request = {
140
185
  libraryReferences: config.libraryReferences,
@@ -145,8 +190,8 @@ export const runControllerSession = async (
145
190
  // Kept across jobs: generating a device tree takes minutes, and the same inputs make the same one.
146
191
  caches: await toWindowsPath(root, config.codesys.wine),
147
192
  ...(config.deviceTree && {
148
- deviceTree: await writeDeviceTree(config.deviceTree, requestDirectory),
149
- devices: await toWindowsPath(requestDirectory, config.codesys.wine),
193
+ deviceTree: await writeDeviceTree(config.deviceTree, jobDirectory),
194
+ devices: await toWindowsPath(jobDirectory, config.codesys.wine),
150
195
  }),
151
196
  }),
152
197
  libraries: await Promise.all(
@@ -165,26 +210,25 @@ export const runControllerSession = async (
165
210
  online: config.online,
166
211
  controller: config.controller,
167
212
  project: await toWindowsPath(project, config.codesys.wine),
168
- progress: await toWindowsPath(resolve(requestDirectory, "progress.log"), config.codesys.wine),
213
+ progress: await toWindowsPath(resolve(jobDirectory, "progress.log"), config.codesys.wine),
214
+ ...(credentialsFile && {
215
+ credentialsFile: await toWindowsPath(credentialsFile, config.codesys.wine),
216
+ }),
169
217
  baseline,
170
218
  read: options.read ?? [],
171
219
  };
172
220
  await writeFile(resolve(requestDirectory, "request.json"), JSON.stringify(request, null, 2));
173
- const worker = createCodesysWorker(jobConfig);
174
- return withWorkerShutdown(jobConfig, worker, async () => {
175
- const result = await withWorkerLock(jobConfig, async () => {
176
- const session = await worker.launch();
177
- await worker.request(session, { action: "online" }, config.timeouts.build);
178
- return z
179
- .object({
180
- applicationGuid: guid,
181
- application: z.string().optional(),
182
- controllerIds: z.strictObject({ codeId: guid, dataId: guid }),
183
- state: z.string(),
184
- values: z.record(z.string(), z.string()),
185
- })
186
- .parse(JSON.parse(await readFile(resolve(requestDirectory, "result.json"), "utf8")));
187
- });
221
+ try {
222
+ await online.run();
223
+ const result = z
224
+ .object({
225
+ applicationGuid: guid,
226
+ application: z.string().optional(),
227
+ controllerIds: z.strictObject({ codeId: guid, dataId: guid }),
228
+ state: z.string(),
229
+ values: z.record(z.string(), z.string()),
230
+ })
231
+ .parse(JSON.parse(await readFile(resolve(requestDirectory, "result.json"), "utf8")));
188
232
  const receipt = controllerResultSchema.parse({
189
233
  ...result,
190
234
  application: result.application ?? baseline?.application,
@@ -237,11 +281,16 @@ export const runControllerSession = async (
237
281
  }
238
282
  await writeJson(resultPath, receipt);
239
283
  return receipt;
240
- }).finally(async () => {
284
+ } finally {
285
+ if (credentialsFile) await rm(credentialsFile, { force: true });
286
+ // The worker closed an opened project this operation didn't use.
287
+ if (opened && opened !== prepared) {
288
+ await rm(resolve(root, "generations", opened.generation), { recursive: true, force: true });
289
+ }
241
290
  if (operation === "inspect") {
242
291
  await rm(candidate, { recursive: true, force: true });
243
292
  }
244
- });
293
+ }
245
294
  };
246
295
 
247
296
  /**
@@ -284,6 +333,8 @@ export const serveControllerLogin = async (
284
333
  // The worker has one mailbox, so commands run one at a time.
285
334
  let previous: Promise<unknown> = Promise.resolve();
286
335
  const server = Bun.serve({
336
+ // The agent calls through `docker exec`, so nothing else on the network can.
337
+ hostname: "127.0.0.1",
287
338
  port: options.port,
288
339
  idleTimeout: 0,
289
340
  fetch: async (request) => {
@@ -297,10 +348,7 @@ export const serveControllerLogin = async (
297
348
  try {
298
349
  return Response.json({ ok: true, result: await result });
299
350
  } catch (error) {
300
- // The worker reports a Python traceback; its last line names the failure.
301
- const message = error instanceof Error ? error.message : String(error);
302
- const reason = message.trim().split("\n").at(-1) ?? message;
303
- return Response.json({ ok: false, error: reason.replace(/^\w+: /, "") });
351
+ return Response.json({ ok: false, error: failureReason(error) });
304
352
  } finally {
305
353
  if (close) closed.resolve();
306
354
  }
@@ -311,3 +359,84 @@ export const serveControllerLogin = async (
311
359
  }),
312
360
  );
313
361
  };
362
+
363
+ /**
364
+ * Called inside the builder container: keeps one CODESYS worker running and serves downloads and
365
+ * online changes on `port`, so each skips starting the container and CODESYS. Each
366
+ * POST names its own config, written for that release. The agent runs one operation at a time, and
367
+ * none while a login is open, so this container doesn't hold the output directory's flock.
368
+ */
369
+ export const serveWarmController = async (
370
+ config: ResolvedConfig,
371
+ options: {
372
+ imageId: string;
373
+ port: number;
374
+ loadConfig: (path: string) => Promise<ResolvedConfig>;
375
+ },
376
+ ) => {
377
+ const warmConfig = { ...config, outDir: resolve(config.outDir, ".codesys/controller/warm") };
378
+ // One container owns this directory at a time; a stopped one can leave its worker session behind.
379
+ await rm(warmConfig.outDir, { recursive: true, force: true });
380
+ const worker = createCodesysWorker(warmConfig);
381
+ const session = await worker.launch();
382
+ const root = resolve(config.outDir, ".codesys/controller");
383
+ const online: OnlineWorker = {
384
+ requestDirectory: resolve(warmConfig.outDir, ".codesys/controller"),
385
+ run: () => worker.request(session, { action: "online" }, config.timeouts.build),
386
+ };
387
+ /** Opens a copy of the committed baseline, so the next online change needn't open one. */
388
+ const prepare = async () => {
389
+ const pointer = await readFile(resolve(root, "current.json"), "utf8").catch(() => undefined);
390
+ if (!pointer) return;
391
+ const baseline = pointerSchema.parse(JSON.parse(pointer)).generation;
392
+ const generation = randomUUID();
393
+ const project = resolve(root, "generations", generation, "project");
394
+ await cp(resolve(root, "generations", baseline, "project"), project, { recursive: true });
395
+ await worker.request(
396
+ session,
397
+ {
398
+ action: "prepare",
399
+ project: await toWindowsPath(resolve(project, "Application.project"), config.codesys.wine),
400
+ },
401
+ config.timeouts.build,
402
+ );
403
+ online.prepared = { baseline, generation };
404
+ };
405
+ // A failed prepare only costs the next operation the time to open the project itself.
406
+ const prepareNext = () =>
407
+ prepare().catch((error: unknown) => {
408
+ console.error(`Could not open the next project ahead: ${failureReason(error)}`);
409
+ });
410
+ let preparing = prepareNext();
411
+ Bun.serve({
412
+ hostname: "127.0.0.1",
413
+ port: options.port,
414
+ idleTimeout: 0,
415
+ fetch: async (request) => {
416
+ if (request.method === "GET") return Response.json({ ok: true });
417
+ await preparing;
418
+ try {
419
+ const body = warmOperationSchema.parse(await request.json());
420
+ const result = await runControllerSession(
421
+ await options.loadConfig(body.request),
422
+ body.operation,
423
+ {
424
+ imageId: options.imageId,
425
+ operationId: body.operationId,
426
+ initializeUser: body.initializeUser,
427
+ credentials: body.credentials,
428
+ },
429
+ online,
430
+ );
431
+ return Response.json({ ok: true, result });
432
+ } catch (error) {
433
+ return Response.json({ ok: false, error: failureReason(error) });
434
+ } finally {
435
+ online.prepared = undefined;
436
+ preparing = prepareNext();
437
+ }
438
+ },
439
+ });
440
+ // Serves until the container stops; the next one clears what this one leaves behind.
441
+ await new Promise(() => {});
442
+ };
package/src/docker-run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { copyFile, cp, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { basename, posix, relative, resolve, sep } from "node:path";
5
5
  import type { ResolvedConfig } from "./config.ts";
6
6
 
@@ -39,7 +39,12 @@ export const runDocker = (args: string[], failure: (status: string | number | nu
39
39
  const bindMount = (source: string, target: string, readonly = false) =>
40
40
  `type=bind,"source=${source.replaceAll('"', '""')}",target=${target}${readonly ? ",readonly" : ""}`;
41
41
 
42
- async function mountLibraries(libraries: ResolvedConfig["libraries"], directory: string) {
42
+ /** Copies the libraries to `directory`, which the container sees as `root`. */
43
+ async function mountLibraries(
44
+ libraries: ResolvedConfig["libraries"],
45
+ directory: string,
46
+ root = "/input",
47
+ ) {
43
48
  if (!libraries?.length) return undefined;
44
49
  await mkdir(resolve(directory, "libraries"), { recursive: true });
45
50
  return Promise.all(
@@ -49,11 +54,57 @@ async function mountLibraries(libraries: ResolvedConfig["libraries"], directory:
49
54
  throw new Error(`Library changed after installation: ${library.title}`);
50
55
  const filename = `${library.sha256}-${basename(library.path)}`;
51
56
  await writeFile(resolve(directory, "libraries", filename), data);
52
- return { ...library, path: `/input/libraries/${filename}` };
57
+ return { ...library, path: `${root}/libraries/${filename}` };
53
58
  }),
54
59
  );
55
60
  }
56
61
 
62
+ /** The config a builder container reads: host-only settings removed, paths inside the container. */
63
+ const containerConfig = (
64
+ config: ResolvedConfig,
65
+ paths: { sourceDir: string; template: string },
66
+ libraries: ResolvedConfig["libraries"] | [],
67
+ ) => ({
68
+ ...config,
69
+ libraries,
70
+ configFile: undefined,
71
+ docker: undefined,
72
+ codesys: undefined,
73
+ sourceDir: paths.sourceDir,
74
+ entry: posix.resolve(
75
+ paths.sourceDir,
76
+ relative(config.sourceDir, config.entry).split(sep).join("/"),
77
+ ),
78
+ template: paths.template,
79
+ outDir: "/output",
80
+ runtime: undefined,
81
+ });
82
+
83
+ /**
84
+ * Copies one operation's sources, template and libraries to `directory` in the output directory,
85
+ * which a warm container sees as `root`, and writes their config there. Returns its container path.
86
+ */
87
+ export const writeOperationRequest = async (
88
+ config: ResolvedConfig,
89
+ directory: string,
90
+ root: string,
91
+ ) => {
92
+ await mkdir(directory, { recursive: true });
93
+ await cp(config.sourceDir, resolve(directory, "sources"), { recursive: true });
94
+ await copyFile(config.template, resolve(directory, "template.project"));
95
+ await writeFile(
96
+ resolve(directory, "config.json"),
97
+ JSON.stringify(
98
+ containerConfig(
99
+ config,
100
+ { sourceDir: `${root}/sources`, template: `${root}/template.project` },
101
+ await mountLibraries(config.libraries, directory, root),
102
+ ),
103
+ ),
104
+ );
105
+ return `${root}/config.json`;
106
+ };
107
+
57
108
  /**
58
109
  * Write the builder container's request and return the `docker run` options that precede
59
110
  * the image; the caller chooses `--rm` or `--detach`. The request names only container paths;
@@ -67,21 +118,13 @@ export const prepareContainerRun = async (
67
118
  ) => {
68
119
  await writeFile(
69
120
  resolve(requestDirectory, "config.json"),
70
- JSON.stringify({
71
- ...config,
72
- libraries: options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],
73
- configFile: undefined,
74
- docker: undefined,
75
- codesys: undefined,
76
- sourceDir: "/sources",
77
- entry: posix.resolve(
78
- "/sources",
79
- relative(config.sourceDir, config.entry).split(sep).join("/"),
121
+ JSON.stringify(
122
+ containerConfig(
123
+ config,
124
+ { sourceDir: "/sources", template: "/template.project" },
125
+ options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],
80
126
  ),
81
- template: "/template.project",
82
- outDir: "/output",
83
- runtime: undefined,
84
- }),
127
+ ),
85
128
  );
86
129
  return [
87
130
  "--init",
@@ -32,6 +32,7 @@ type Session = z.infer<typeof sessionSchema>;
32
32
 
33
33
  type WorkerCommand =
34
34
  | { action: "build" | "online" | "ping" | "stop" }
35
+ | { action: "prepare"; project: string }
35
36
  | { action: "login"; arguments: Record<string, unknown> };
36
37
 
37
38
  const hasCode = (error: unknown, code: string) =>
package/src/index.ts CHANGED
@@ -19,7 +19,9 @@ export {
19
19
  type LoginResult,
20
20
  loginResultSchema,
21
21
  openControllerLogin,
22
+ startWarmController,
22
23
  updateController,
24
+ updateWarmController,
23
25
  } from "./controller-online";
24
26
  export { type CodesysCompilerDiagnostic, CodesysCompilerError } from "./diagnostics";
25
27
  export { runProject } from "./run";