querysub 0.528.0 → 0.530.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.
@@ -0,0 +1,17 @@
1
+ import type { qreact as qreactType } from "./qreact";
2
+
3
+ // qreact.tsx assigns itself onto globalThis at runtime, but only files that import { qreact } see it in the type system. Sliftutils' .tsx sources get pulled into our program (its .d.ts files' relative imports resolve to the neighboring sources) and compile under our qreact jsxFactory without importing it — this ambient declaration gives them the global qreact value and the global JSX fallback namespace.
4
+ declare global {
5
+ const qreact: typeof qreactType;
6
+ namespace JSX {
7
+ type IntrinsicElements = qreactType.JSX.IntrinsicElements;
8
+ type Element = qreactType.JSX.Element;
9
+ type IntrinsicClassAttributes<T = unknown> = qreactType.JSX.IntrinsicClassAttributes<T>;
10
+ interface ElementClass {
11
+ render: any;
12
+ }
13
+ interface ElementAttributesProperty {
14
+ props: {};
15
+ }
16
+ }
17
+ }
@@ -11,6 +11,7 @@ import { managementPageURL } from "../../diagnostics/managementPages";
11
11
  import { t } from "../../2-proxy/schema2";
12
12
  import { Querysub } from "../../4-querysub/Querysub";
13
13
  import { getLogViewerParams } from "../../diagnostics/logs/IndexedLogs/LogViewerParams";
14
+ import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
14
15
 
15
16
  export class MachineDetailPage extends qreact.Component {
16
17
  render() {
@@ -119,16 +120,7 @@ export class MachineDetailPage extends qreact.Component {
119
120
  <ShowMore className={css.whiteSpace("pre-wrap")} maxHeight={80}>
120
121
  {(() => {
121
122
  if (typeof value === "object") {
122
- return <div className={css.pad2(6, 2).hsla(0, 0, 0, 0.1).relative}>
123
- <div className={
124
- css.absolute.pos(0, 0)
125
- .size(`${value.value / value.max * 100}%`, "100%")
126
- .hsl(0, 0, 70)
127
- } />
128
- <div className={css.relative}>
129
- {value.type} ({formatNumber(value.value)} / {formatNumber(value.max)})
130
- </div>
131
- </div>;
123
+ return <UsageBar label={value.type} value={value.value} max={value.max} {...getUsageThresholds(value.type)} />;
132
124
  }
133
125
  return value;
134
126
  })()}
@@ -11,6 +11,7 @@ import { formatDateJSX } from "../../misc/formatJSX";
11
11
  import { DeployMachineButtons, RenderGitRefInfo } from "./deployButtons";
12
12
  import { isDefined } from "../../misc";
13
13
  import { Anchor, createLink } from "../../library-components/ATag";
14
+ import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
14
15
 
15
16
  export class MachinesListPage extends qreact.Component {
16
17
  state = t.state({
@@ -182,17 +183,7 @@ export class MachinesListPage extends qreact.Component {
182
183
  <RenderGitRefInfo gitRef={machineInfo.gitRef} />
183
184
  {Object.values(machineInfo.info).map(value => {
184
185
  if (typeof value === "string") return undefined;
185
- return <div className={css.pad2(6, 2).hsla(0, 0, 0, 0.1).relative}>
186
- <div className={
187
- css.absolute.pos(0, 0)
188
- .size(`${value.value / value.max * 100}%`, "100%")
189
- .hsl(0, 0, 70)
190
- } />
191
- <div className={css.relative}>
192
- {value.type} ({formatNumber(value.value)} / {formatNumber(value.max)})
193
- </div>
194
- </div>;
195
-
186
+ return <UsageBar label={value.type} value={value.value} max={value.max} {...getUsageThresholds(value.type)} />;
196
187
  })}
197
188
  </div>
198
189
  <div className={css.vbox(4).flexGrow(1)}>
@@ -5,7 +5,7 @@ import { css } from "typesafecss";
5
5
  import { t } from "../../2-proxy/schema2";
6
6
  import { Querysub } from "../../4-querysub/Querysub";
7
7
  import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
8
- import { formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
8
+ import { formatDateTime, formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
9
  import { InputPicker } from "../../library-components/InputPicker";
10
10
  import { MachinePicker } from "./MachinePicker";
11
11
  import { deepCloneJSON, nextId, sort, timeInSecond } from "socket-function/src/misc";
@@ -46,7 +46,9 @@ export class ServiceDetailPage extends qreact.Component {
46
46
  }),
47
47
  // Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
48
48
  switchTime: t.number(0),
49
- // Seconds; 0 means use the service's configured overlapTime
49
+ // Seconds until the release goes live; 0 means use the default (DEFAULT_OVERLAP_TIME)
50
+ releaseDelayOverride: t.number(0),
51
+ // Seconds the old instances outlive the release; 0 means use the default (DEFAULT_OVERLAP_TIME). The no-overlap deploy buttons are how you deploy with an actual 0.
50
52
  overlapOverride: t.number(0),
51
53
  });
52
54
 
@@ -123,6 +125,7 @@ export class ServiceDetailPage extends qreact.Component {
123
125
  Querysub.commit(() => {
124
126
  this.state.editorState = stripReleaseState(deployConfig);
125
127
  this.state.switchTime = 0;
128
+ this.state.releaseDelayOverride = 0;
126
129
  this.state.overlapOverride = 0;
127
130
  });
128
131
  } finally {
@@ -151,11 +154,13 @@ export class ServiceDetailPage extends qreact.Component {
151
154
  content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
152
155
  <div className={css.vbox(12).pad2(20).hsl(0, 0, 12).colorhsl(0, 0, 90).bord2(0, 0, 40) + " keepModalsOpen"}>
153
156
  <div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
154
- <div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
157
+ <div>{deployConfig.parameters.overlapTime
158
+ ? `This config becomes live immediately. The old instances keep running for ${formatTime(deployConfig.parameters.overlapTime)} before shutting down.`
159
+ : "This config becomes live immediately. The old instances are shut down right away, with no overlap."}</div>
155
160
  <div className={css.hbox(10)}>
156
161
  <Button hue={0} onClick={() => {
157
162
  modal && modal.close();
158
- deployConfig.parameters.releaseTime = undefined;
163
+ deployConfig.parameters.releaseTime = Date.now();
159
164
  this.deployConfig(deployConfig);
160
165
  }}>⚡ Force Deploy Now</Button>
161
166
  <Button onClick={() => modal && modal.close()}>Cancel</Button>
@@ -251,7 +256,18 @@ export class ServiceDetailPage extends qreact.Component {
251
256
  if (!releaseTime || Date.now() >= releaseTime || !originalConfig.oldParameters) return undefined;
252
257
  return {
253
258
  switchTime: releaseTime,
254
- overlapTime: originalConfig.parameters.overlapTime || DEFAULT_OVERLAP_TIME,
259
+ overlapTime: originalConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
260
+ };
261
+ })();
262
+ // By our calculation both the old and new parameters' instances are running (the release passed, but the old instances haven't been shut down yet). Purely time-based — we don't verify the instances are actually running.
263
+ const overlappingDeploy = (() => {
264
+ let releaseTime = originalConfig.parameters.releaseTime || 0;
265
+ let overlapTime = originalConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
266
+ if (!releaseTime || !originalConfig.oldParameters) return undefined;
267
+ if (Date.now() < releaseTime || Date.now() >= releaseTime + overlapTime) return undefined;
268
+ return {
269
+ oldParameters: originalConfig.oldParameters,
270
+ shutdownTime: releaseTime + overlapTime,
255
271
  };
256
272
  })();
257
273
 
@@ -378,16 +394,15 @@ export class ServiceDetailPage extends qreact.Component {
378
394
  this.updateEditorState(updated);
379
395
  }}
380
396
  />
381
- <InputLabel
382
- label="Deploy overlap (seconds)"
383
- integer
384
- value={Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond)}
385
- onChangeValue={value => {
386
- let updated = deepCloneJSON(config);
387
- updated.parameters.overlapTime = (Number(value) || 0) * timeInSecond;
388
- this.updateEditorState(updated);
389
- }}
390
- />
397
+ <div className={css.hbox(12)}>
398
+ {(() => {
399
+ let lastDeployTime = getLiveServiceParameters(originalConfig).releaseTime;
400
+ if (!lastDeployTime || lastDeployTime > now) return undefined;
401
+ return <span>
402
+ Last deploy at {formatDateTime(lastDeployTime)} ({formatTime(now - lastDeployTime)} ago)
403
+ </span>;
404
+ })()}
405
+ </div>
391
406
  {/* Machine Status */}
392
407
  {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
393
408
  <h3>Deployed Machines ({machineIds.length})</h3>
@@ -668,7 +683,7 @@ export class ServiceDetailPage extends qreact.Component {
668
683
  <ParametersDiff base={getLiveServiceParameters(originalConfig)} next={originalConfig.parameters} />
669
684
  <div className={css.vbox(4)}>
670
685
  <div>
671
- Live instance deployed at {scheduledDeploy.switchTime - scheduledDeploy.overlapTime <= now && `${formatTime(now - (scheduledDeploy.switchTime - scheduledDeploy.overlapTime))} ago` || `in ${formatTime(scheduledDeploy.switchTime - scheduledDeploy.overlapTime - now)}`} ({formatDateTimeDetailed(scheduledDeploy.switchTime - scheduledDeploy.overlapTime)})
686
+ Old instances shut down in {formatTime(scheduledDeploy.switchTime + scheduledDeploy.overlapTime - now)} ({formatDateTimeDetailed(scheduledDeploy.switchTime + scheduledDeploy.overlapTime)})
672
687
  </div>
673
688
  <div className={css.hbox(8)}>
674
689
  <button
@@ -684,10 +699,27 @@ export class ServiceDetailPage extends qreact.Component {
684
699
  }}>
685
700
  Cancel Scheduled Deploy
686
701
  </button>
702
+ <button
703
+ className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
704
+ disabled={this.state.isDeploying}
705
+ title="Changes the scheduled deploy to go live immediately"
706
+ onClick={() => {
707
+ this.confirmForceDeployNow(deepCloneJSON(stripReleaseState(originalConfig)));
708
+ }}>
709
+ ⚡ Force Schedule Now
710
+ </button>
687
711
  </div>
688
712
  </div>
689
713
  </div>}
690
714
 
715
+ {/* The release went live, but the old instances are still running out their overlap */}
716
+ {overlappingDeploy && <div className={css.vbox(10).pad2(12).fillWidth.bord2(280, 60, 50).hsl(280, 40, 95)}>
717
+ <div className={css.boldStyle.fontSize(15)}>
718
+ 🔀 Overlapping configs — the new config is live, and the old instances shut down in {formatTime(overlappingDeploy.shutdownTime - now)} ({formatDateTimeDetailed(overlappingDeploy.shutdownTime)})
719
+ </div>
720
+ <ParametersDiff base={overlappingDeploy.oldParameters} next={originalConfig.parameters} />
721
+ </div>}
722
+
691
723
  {/* The EDITOR's changes (browser-only) — deploying them schedules a (new) deploy */}
692
724
  {(() => {
693
725
  if (!hasUnsavedChanges) return undefined;
@@ -707,18 +739,19 @@ export class ServiceDetailPage extends qreact.Component {
707
739
  <span title={`${configCompareKey(config)} !== ${configCompareKey(deployedConfig)}`}>Only the title / notes changed, so this saves immediately</span>
708
740
  </div>;
709
741
  }
710
- let overlapSeconds = this.state.overlapOverride || Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond);
742
+ let releaseDelaySeconds = this.state.releaseDelayOverride || Math.round(DEFAULT_OVERLAP_TIME / timeInSecond);
743
+ let releaseDelay = releaseDelaySeconds * timeInSecond;
744
+ let overlapSeconds = this.state.overlapOverride || Math.round(DEFAULT_OVERLAP_TIME / timeInSecond);
711
745
  let overlap = overlapSeconds * timeInSecond;
712
746
  let scheduledTime = this.state.switchTime;
713
- let notEnoughTime = !!scheduledTime && scheduledTime > now && scheduledTime - now < overlap;
714
- const makeDeployConfig = (releaseTime: number) => {
747
+ const makeDeployConfig = (releaseTime: number, overlapTime = overlap) => {
715
748
  let deployConfig = deepCloneJSON(config);
716
749
  deployConfig.parameters.releaseTime = releaseTime;
717
- deployConfig.parameters.overlapTime = overlap;
750
+ deployConfig.parameters.overlapTime = overlapTime;
718
751
  return deployConfig;
719
752
  };
720
- // The switch time is inferred from the overlap, unless one is explicitly set (in which case the overlap only controls when the new instances start)
721
- let effectiveSwitchTime = scheduledTime || now + overlap;
753
+ // The switch time is inferred from the release delay, unless one is explicitly set
754
+ let effectiveSwitchTime = scheduledTime || now + releaseDelay;
722
755
  return <div className={css.vbox(10).pad2(12).fillWidth.bord2(45, 70, 45).hsl(45, 60, 95)}>
723
756
  <div className={css.boldStyle.fontSize(15)}>
724
757
  ✏️ Edited (only in this browser, until deployed)
@@ -729,10 +762,19 @@ export class ServiceDetailPage extends qreact.Component {
729
762
  className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
730
763
  disabled={this.state.isDeploying}
731
764
  onClick={() => {
732
- this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + overlap));
765
+ this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + releaseDelay));
733
766
  }}>
734
767
  {this.state.isDeploying && "⏳ Scheduling..." || scheduledDeploy && "🔁 Replace Scheduled Deploy" || "🚀 Schedule Deploy"}
735
768
  </button>
769
+ <button
770
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(35, 70, 85)}
771
+ disabled={this.state.isDeploying}
772
+ title="Deploys with the configured release delay, but the old instances shut down immediately at release time (overlap of 0)"
773
+ onClick={() => {
774
+ this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + releaseDelay, 0));
775
+ }}>
776
+ Deploy, No Overlap
777
+ </button>
736
778
  <div className={css.opacity(scheduledTime && 1 || 0.5)}>
737
779
  <InputLabel
738
780
  label="Switch at"
@@ -749,34 +791,48 @@ export class ServiceDetailPage extends qreact.Component {
749
791
  onClick={() => {
750
792
  this.state.switchTime = 0;
751
793
  }}>
752
- ↺ Reset to overlap-based time
794
+ ↺ Reset to delay-based time
753
795
  </button>}
754
796
  <div className={css.opacity(scheduledTime && 0.5 || 1)}>
755
797
  <InputLabel
756
- label="Overlap (seconds)"
798
+ label="Release in (seconds)"
757
799
  integer
758
- value={overlapSeconds}
800
+ value={releaseDelaySeconds}
759
801
  onChangeValue={value => {
760
- this.state.overlapOverride = Number(value) || 0;
802
+ this.state.releaseDelayOverride = Number(value) || 0;
761
803
  }}
762
804
  />
763
805
  </div>
806
+ <InputLabel
807
+ label="Overlap (seconds)"
808
+ integer
809
+ value={overlapSeconds}
810
+ onChangeValue={value => {
811
+ this.state.overlapOverride = Number(value) || 0;
812
+ }}
813
+ />
764
814
  </div>
765
815
  {!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
766
816
  ⚠️ The switch time is in the past — deploying switches immediately
767
817
  </span>}
768
- {notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
769
- ⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
770
- </span>}
771
818
  <div className={css.hbox(8)}>
772
819
  <button
773
820
  className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
774
821
  disabled={this.state.isDeploying}
775
822
  onClick={() => {
776
- this.confirmForceDeployNow(makeDeployConfig(0));
823
+ this.confirmForceDeployNow(makeDeployConfig(Date.now()));
777
824
  }}>
778
825
  ⚡ Force Deploy Now
779
826
  </button>
827
+ <button
828
+ className={css.pad2(12, 8).button.bord2(0, 90, 30).hsl(0, 85, 65).colorhsl(0, 0, 100).fontWeight("bold")}
829
+ disabled={this.state.isDeploying}
830
+ title="Deploys immediately AND shuts down the old instances immediately (overlap of 0)"
831
+ onClick={() => {
832
+ this.confirmForceDeployNow(makeDeployConfig(Date.now(), 0));
833
+ }}>
834
+ ⚡ Force Deploy Now, No Overlap
835
+ </button>
780
836
  </div>
781
837
  </div>;
782
838
  })()}
@@ -790,21 +846,27 @@ export class ServiceDetailPage extends qreact.Component {
790
846
  )}
791
847
 
792
848
  {/* Config Editor */}
793
- <TypedConfigEditor
794
- value={config}
795
- onValueChange={(newValue) => {
796
- this.updateEditorState(newValue as ServiceConfig);
797
- }}
798
- typeDefinition={serviceConfigType}
799
- sizeClassName={css.size(1000, 600)}
800
- />
801
- {hasUnsavedChanges && <button
802
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
803
- onClick={() => {
804
- this.state.editorState = undefined;
805
- }}>
806
- Discard Changes
807
- </button>}
849
+ {(() => {
850
+ const discardButton = hasUnsavedChanges && <button
851
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
852
+ onClick={() => {
853
+ this.state.editorState = undefined;
854
+ }}>
855
+ Discard Changes
856
+ </button>;
857
+ return <>
858
+ {discardButton}
859
+ <TypedConfigEditor
860
+ value={config}
861
+ onValueChange={(newValue) => {
862
+ this.updateEditorState(newValue as ServiceConfig);
863
+ }}
864
+ typeDefinition={serviceConfigType}
865
+ sizeClassName={css.size(1000, 600)}
866
+ />
867
+ {discardButton}
868
+ </>;
869
+ })()}
808
870
  </div>;
809
871
  }
810
872
  }
@@ -46,7 +46,7 @@ export class UpdateButtons extends qreact.Component<{
46
46
  state = t.state({
47
47
  isDeploying: t.type(false),
48
48
  });
49
- // Deploys all outdated services to latestRef. Scheduled releases use the configured overlap so the new instances are running before the old ones shut down; immediate skips the release entirely (old instances restart right away, no overlap).
49
+ // Deploys all outdated services to latestRef. Scheduled releases delay the release by the configured overlap (an unrelated value, but a reasonable delay); immediate releases right away.
50
50
  private deployAll(outdatedServices: ServiceConfig[], latestRef: string, immediate: boolean) {
51
51
  this.state.isDeploying = true;
52
52
  // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
@@ -56,7 +56,7 @@ export class UpdateButtons extends qreact.Component<{
56
56
  if (immediate) {
57
57
  updated.parameters.releaseTime = undefined;
58
58
  } else {
59
- let overlap = updated.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
59
+ let overlap = updated.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
60
60
  updated.parameters.releaseTime = Date.now() + overlap;
61
61
  }
62
62
  return updated;
@@ -183,8 +183,8 @@ export class UpdateServiceButtons extends qreact.Component<{
183
183
  // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
184
184
  let service = deepCloneJSON(this.props.service);
185
185
  service.parameters.gitRef = gitInfo.latestRef;
186
- // Release with the configured overlap, so the new instances are running before the old ones shut down
187
- let overlap = service.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
186
+ // Delay the release by the configured overlap (an unrelated value, but a reasonable delay)
187
+ let overlap = service.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
188
188
  service.parameters.releaseTime = Date.now() + overlap;
189
189
  Querysub.onCommitFinished(async () => {
190
190
  try {
@@ -25,6 +25,7 @@ import { onServiceConfigChange, scheduleNodeShutdown } from "./machineController
25
25
  import { PromiseObj } from "../promise";
26
26
  import path from "path";
27
27
  import { fsExistsAsync } from "../fs";
28
+ import { ALIVE_WINDOW_FOREVER, ParametersTimelineEntry, syncParametersTimelineFiles } from "./parametersTimeline";
28
29
 
29
30
  // The deployed-service stdout log (pipe.txt) is a rolling buffer: once it grows
30
31
  // to PIPE_FILE_LINE_LIMIT lines it is truncated down to PIPE_FILE_LINE_KEEP. The
@@ -132,9 +133,9 @@ const SCREEN_SUFFIX = "-dply";
132
133
  export function getScreenName(config: { serviceKey: string; index: number }): string {
133
134
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
134
135
  }
135
- // The new instances started during a release overlap. They cover the service while the canonical screens are restarted with the new config at release time, and are killed shortly after.
136
- export function getNextScreenName(config: { serviceKey: string; index: number }): string {
137
- return getScreenName({ serviceKey: config.serviceKey + "-next", index: config.index });
136
+ // The old instances kept alive during a release overlap. The canonical screens restart with the new config at release time; these keep the OLD version serving until releaseTime + overlapTime, and are killed shortly after.
137
+ export function getOldScreenName(config: { serviceKey: string; index: number }): string {
138
+ return getScreenName({ serviceKey: config.serviceKey + "-old", index: config.index });
138
139
  }
139
140
 
140
141
  export async function streamScreenOutput(config: {
@@ -585,35 +586,41 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
585
586
  let launchesPerService = new Map<string, number>();
586
587
  let lastLaunchedTimePerService = new Map<string, number>();
587
588
 
588
- // Starts (or verifies) the "-next" instance of a service during a release overlap, running the new (not yet live) parameters. It covers the service while the canonical screens restart with the new parameters at release time, and is killed by the unused-screen cleanup afterwards (once the release passes we stop ensuring it).
589
- async function ensureNextInstance(config: {
590
- next: ServiceParameters;
589
+ // Starts (or verifies) the "-old" instance of a service during a release overlap, running the old (no longer live) parameters. It keeps the old version serving while the new canonical instances start up, and is killed by the unused-screen cleanup afterwards (once releaseTime + overlapTime passes we stop ensuring it).
590
+ async function ensureOldInstance(config: {
591
+ old: ServiceParameters;
592
+ aliveWindow: [number, number];
591
593
  index: number;
592
594
  screenNamesUsed: Set<string>;
593
595
  screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
594
596
  }) {
595
- let { next, index } = config;
596
- let nextScreenName = getNextScreenName({ serviceKey: next.key, index });
597
- config.screenNamesUsed.add(nextScreenName);
597
+ let { old, index } = config;
598
+ let oldScreenName = getOldScreenName({ serviceKey: old.key, index });
599
+ config.screenNamesUsed.add(oldScreenName);
600
+ let shutdownTime = config.aliveWindow[1];
601
+ const syncTimeline = async () => {
602
+ let pid = parseInt(config.screenStateMap.get(oldScreenName)?.pid || "") || undefined;
603
+ await syncParametersTimelineFiles(os.homedir() + "/" + SERVICE_FOLDER + oldScreenName + "/", [{ pid, aliveWindow: config.aliveWindow, parameters: old }]);
604
+ };
598
605
 
599
606
  let root = os.homedir() + "/" + SERVICE_FOLDER;
600
- let folder = root + nextScreenName + "/";
607
+ let folder = root + oldScreenName + "/";
601
608
  let gitFolder = folder + "git/";
602
609
  await fs.promises.mkdir(gitFolder, { recursive: true });
603
- if (next.gitRef) {
610
+ if (old.gitRef) {
604
611
  let prevGitRef = "";
605
612
  try {
606
613
  prevGitRef = await getGitRefLive(gitFolder);
607
614
  } catch { }
608
615
  await ensureGitSynced({
609
616
  gitFolder,
610
- repoUrl: next.repoUrl,
611
- gitRef: next.gitRef,
617
+ repoUrl: old.repoUrl,
618
+ gitRef: old.gitRef,
612
619
  });
613
620
  let afterGitRef = await getGitRefLive(gitFolder);
614
621
  let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
615
622
  if (afterGitRef !== prevGitRef || nodeModulesMissing) {
616
- console.log(green(`Yarn installing for ${magenta(nextScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
623
+ console.log(green(`Yarn installing for ${magenta(oldScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
617
624
  await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
618
625
  }
619
626
  }
@@ -623,23 +630,27 @@ async function ensureNextInstance(config: {
623
630
  if (await fsExistsAsync(parameterPath)) {
624
631
  prevParameters = await fs.promises.readFile(parameterPath, "utf8");
625
632
  }
626
- let newParametersString = JSON.stringify(next);
627
- let sameParameters = sameRestartParameters(prevParameters, next);
628
- let screenIsRunning = config.screenStateMap.get(nextScreenName)?.isProcessRunning;
633
+ let newParametersString = JSON.stringify(old);
634
+ let sameParameters = sameRestartParameters(prevParameters, old);
635
+ let screenIsRunning = config.screenStateMap.get(oldScreenName)?.isProcessRunning;
629
636
  if (sameParameters && screenIsRunning) {
630
637
  if (prevParameters !== newParametersString) {
631
638
  await fs.promises.writeFile(parameterPath, newParametersString);
632
639
  }
633
- console.log(green(`Verified release-overlap instance ${magenta(nextScreenName)} is running`));
640
+ await notifyServiceShutdown(oldScreenName, shutdownTime);
641
+ await syncTimeline();
642
+ console.log(green(`Verified release-overlap old instance ${magenta(oldScreenName)} is running`));
634
643
  return;
635
644
  }
636
645
 
637
- console.log(`Starting release-overlap instance ${magenta(nextScreenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
646
+ console.log(`Starting release-overlap old instance ${magenta(oldScreenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
638
647
  await fs.promises.writeFile(parameterPath, newParametersString);
639
648
  await runScreenCommand({
640
- screenName: nextScreenName,
641
- command: next.command,
649
+ screenName: oldScreenName,
650
+ command: old.command,
642
651
  });
652
+ await notifyServiceShutdown(oldScreenName, shutdownTime);
653
+ await syncTimeline();
643
654
  }
644
655
 
645
656
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -656,8 +667,8 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
656
667
  let machineId = getOwnMachineId(getDomain());
657
668
  let allConfigs = await getEffectiveServiceConfigs();
658
669
  let relevantConfigs = allConfigs
659
- .filter(config => getMachineIdList(getLiveServiceParameters(config)).includes(machineId) || getMachineIdList(config.parameters).includes(machineId))
660
- .filter(x => getLiveServiceParameters(x).deploy || x.parameters.deploy);
670
+ .filter(config => getMachineIdList(getLiveServiceParameters(config)).includes(machineId) || getMachineIdList(config.parameters).includes(machineId) || config.oldParameters && getMachineIdList(config.oldParameters).includes(machineId))
671
+ .filter(x => getLiveServiceParameters(x).deploy || x.parameters.deploy || x.oldParameters?.deploy);
661
672
 
662
673
  let machineInfo = await getLiveMachineInfo();
663
674
 
@@ -669,25 +680,29 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
669
680
  for (let record of relevantConfigs) {
670
681
  let config = { ...record, parameters: getLiveServiceParameters(record) };
671
682
  let releaseTime = record.parameters.releaseTime || 0;
683
+ // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
684
+ let overlapTime = record.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
672
685
  let isUpcomingRelease = !!releaseTime && Date.now() < releaseTime && !!record.oldParameters;
673
- let overlapTime = record.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
674
- if (isUpcomingRelease) {
686
+ let isOverlapRunning = !!releaseTime && Date.now() >= releaseTime && Date.now() < releaseTime + overlapTime && !!record.oldParameters;
687
+ if (isUpcomingRelease || isOverlapRunning) {
675
688
  upcomingReleases.push({ releaseTime, overlapTime });
676
- console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): new instances start at ${new Date(releaseTime - overlapTime).toLocaleString()}${Date.now() >= releaseTime - overlapTime && " (overlap running)" || ""}, old instances shut down at ${new Date(releaseTime).toLocaleString()}`));
677
- // The release-overlap instances follow the NEW parameters' machineIds, so machines that are only in the new parameters still start their instances at overlap time (their canonical instances start once the release passes)
678
- if (Date.now() >= releaseTime - overlapTime && record.parameters.deploy) {
679
- let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
680
- for (let i = 0; i < nextTargets.length; i++) {
681
- try {
682
- await ensureNextInstance({
683
- next: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) },
684
- index: i,
685
- screenNamesUsed,
686
- screenStateMap,
687
- });
688
- } catch (e: any) {
689
- console.error(`Error starting release-overlap instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
690
- }
689
+ console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): new instances start at ${new Date(releaseTime).toLocaleString()}${isOverlapRunning && " (overlap running)" || ""}, old instances shut down at ${new Date(releaseTime + overlapTime).toLocaleString()}`));
690
+ }
691
+ // The old-overlap instances follow the OLD parameters' machineIds, so machines being removed by the release still keep the old version alive through the overlap (their canonical screens die at release time)
692
+ if (isOverlapRunning && record.oldParameters?.deploy) {
693
+ let oldParameters = record.oldParameters;
694
+ let oldTargets = getMachineTargets(oldParameters).filter(target => target.machineId === machineId);
695
+ for (let i = 0; i < oldTargets.length; i++) {
696
+ try {
697
+ await ensureOldInstance({
698
+ old: { ...oldParameters, command: applyCommandTemplate(oldParameters.command, oldTargets[i].variables) },
699
+ aliveWindow: [releaseTime, releaseTime + overlapTime],
700
+ index: i,
701
+ screenNamesUsed,
702
+ screenStateMap,
703
+ });
704
+ } catch (e: any) {
705
+ console.error(`Error starting release-overlap old instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
691
706
  }
692
707
  }
693
708
  }
@@ -748,6 +763,24 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
748
763
  let sameParameters = sameRestartParameters(prevParameters, instanceParameters);
749
764
  let screenIsRunning = screenStateMap.get(screenName)?.isProcessRunning;
750
765
 
766
+ const syncTimeline = async (pid: string | undefined) => {
767
+ let entries: ParametersTimelineEntry[] = [{
768
+ pid: parseInt(pid || "") || undefined,
769
+ aliveWindow: [config.parameters.releaseTime || 0, isUpcomingRelease ? releaseTime : ALIVE_WINDOW_FOREVER],
770
+ parameters: instanceParameters,
771
+ }];
772
+ if (isUpcomingRelease) {
773
+ let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
774
+ if (i < futureTargets.length) {
775
+ entries.push({
776
+ aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
777
+ parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
778
+ });
779
+ }
780
+ }
781
+ await syncParametersTimelineFiles(folder, entries);
782
+ };
783
+
751
784
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
752
785
  if (await fsExistsAsync(nodePathId)) {
753
786
  let nodeId = await fs.promises.readFile(nodePathId, "utf8");
@@ -758,6 +791,7 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
758
791
  if (prevParameters !== newParametersString) {
759
792
  await fs.promises.writeFile(parameterPath, newParametersString);
760
793
  }
794
+ await syncTimeline(screenStateMap.get(screenName)?.pid);
761
795
  console.log(green(`Verified ${magenta(screenName)} is running`));
762
796
  continue;
763
797
  }
@@ -798,6 +832,7 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
798
832
  throw new Error(`${logs}\n\nService ${magenta(screenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
799
833
  }
800
834
  console.log(green(`Service ${magenta(screenName)} is verified to be running after starting.`));
835
+ await syncTimeline(foundScreen?.pid);
801
836
 
802
837
  launchCount++;
803
838
  lastLaunchedTime = Date.now();
@@ -838,13 +873,13 @@ async function resyncServices() {
838
873
  }
839
874
  }
840
875
 
841
- // The regular resync poll is too coarse for release boundaries (the overlap start and the release itself), so schedule an extra resync at the next boundary.
876
+ // The regular resync poll is too coarse for release boundaries (the release itself and the overlap end), so schedule an extra resync at the next boundary.
842
877
  let cancelReleaseResync: (() => void) | undefined;
843
878
  function scheduleReleaseResync(upcomingReleases: { releaseTime: number; overlapTime: number }[]) {
844
879
  let now = Date.now();
845
880
  let next: number | undefined;
846
881
  for (let release of upcomingReleases) {
847
- for (let boundary of [release.releaseTime - release.overlapTime, release.releaseTime]) {
882
+ for (let boundary of [release.releaseTime, release.releaseTime + release.overlapTime]) {
848
883
  if (boundary <= now) continue;
849
884
  if (next === undefined || boundary < next) {
850
885
  next = boundary;