@astrale-os/sdk 0.5.0-beta.80 → 0.5.0-beta.82

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0-beta.82](https://github.com/astrale-os/sdk/compare/sdk-v0.5.0-beta.81...sdk-v0.5.0-beta.82) (2026-08-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** align managed publication projection ([#342](https://github.com/astrale-os/sdk/issues/342)) ([269fab1](https://github.com/astrale-os/sdk/commit/269fab13d028d41a27ff50d778c73fd994cf6f22))
9
+
10
+ ## [0.5.0-beta.81](https://github.com/astrale-os/sdk/compare/sdk-v0.5.0-beta.80...sdk-v0.5.0-beta.81) (2026-08-29)
11
+
12
+
13
+ ### Features
14
+
15
+ * **app:** add development session file ([#339](https://github.com/astrale-os/sdk/issues/339)) ([f28e9c6](https://github.com/astrale-os/sdk/commit/f28e9c6fb707b8791a5bc63eba3b616aa7b02342))
16
+
3
17
  ## [0.5.0-beta.80](https://github.com/astrale-os/sdk/compare/sdk-v0.5.0-beta.79...sdk-v0.5.0-beta.80) (2026-08-29)
4
18
 
5
19
 
@@ -10,5 +10,7 @@ export interface DevelopmentContext extends DevelopmentInput {
10
10
  readonly environment: string;
11
11
  readonly overrides: DevelopmentOverrides;
12
12
  readonly signal: AbortSignal;
13
+ /** Report loss of the current ready placement before provider recovery starts. */
14
+ onRecovering?(): Promise<void>;
13
15
  onPlacement(placement: DevelopmentPlacement): Promise<void>;
14
16
  }
@@ -1,4 +1,4 @@
1
1
  export type { DevelopmentContext, DevelopmentOverrides } from './context.js';
2
2
  export type { DevelopmentInput } from './input.js';
3
- export type { DevelopmentPlacement } from './placement.js';
3
+ export type { DevelopmentInstalledRelease, DevelopmentPlacement } from './placement.js';
4
4
  export type { DevelopmentSession } from './session.js';
@@ -2,4 +2,14 @@
2
2
  export interface DevelopmentPlacement {
3
3
  readonly releaseUrl: string;
4
4
  readonly localUrl?: string;
5
+ /** Exact Kernel evidence when this adapter owns installation. */
6
+ readonly installedRelease?: DevelopmentInstalledRelease;
7
+ }
8
+ /** Exact installed Release evidence supplied only by an installation-owning adapter. */
9
+ export interface DevelopmentInstalledRelease {
10
+ readonly origin: string;
11
+ readonly revision: string;
12
+ readonly issuer: string;
13
+ readonly etag: string;
14
+ readonly generation: string;
5
15
  }
@@ -8,6 +8,7 @@ export interface ParsedArgs {
8
8
  readonly format?: LintReportFormat;
9
9
  readonly port?: number;
10
10
  readonly host?: string;
11
+ readonly sessionFile?: string;
11
12
  }
12
13
  /** Parse the frozen `astrale-domain` command grammar without performing effects. */
13
14
  export declare function parseArgs(argv: readonly string[]): ParsedArgs;
@@ -1,4 +1,5 @@
1
1
  import { isIP } from 'node:net';
2
+ import { isAbsolute, normalize } from 'node:path';
2
3
  export const COMMANDS = ['dev', 'build', 'deploy', 'lint', 'package'];
3
4
  /** Parse the frozen `astrale-domain` command grammar without performing effects. */
4
5
  export function parseArgs(argv) {
@@ -6,6 +7,7 @@ export function parseArgs(argv) {
6
7
  const fix = rest.includes('--fix');
7
8
  let port;
8
9
  let host;
10
+ let sessionFile;
9
11
  let format;
10
12
  let environment;
11
13
  const cleaned = [];
@@ -30,6 +32,12 @@ export function parseArgs(argv) {
30
32
  else if (argument.startsWith('--host=')) {
31
33
  host = normalizeHost(argument.slice('--host='.length));
32
34
  }
35
+ else if (argument === '--session-file') {
36
+ sessionFile = sessionFilePath(rest[++index]);
37
+ }
38
+ else if (argument.startsWith('--session-file=')) {
39
+ sessionFile = sessionFilePath(argument.slice('--session-file='.length));
40
+ }
33
41
  else if (argument === '--format') {
34
42
  format = lintFormat(rest[++index]);
35
43
  }
@@ -57,8 +65,9 @@ export function parseArgs(argv) {
57
65
  if (format !== undefined && command !== 'lint') {
58
66
  throw new Error('`--format` is only valid for `lint`.');
59
67
  }
60
- if ((port !== undefined || host !== undefined) && command !== 'dev') {
61
- throw new Error('`--port` and `--host` are only valid for `dev`.');
68
+ if ((port !== undefined || host !== undefined || sessionFile !== undefined) &&
69
+ command !== 'dev') {
70
+ throw new Error('`--port`, `--host`, and `--session-file` are only valid for `dev`.');
62
71
  }
63
72
  if (environment !== undefined && !['dev', 'deploy'].includes(command ?? '')) {
64
73
  throw new Error('`--environment` is only valid for dev or deploy.');
@@ -76,6 +85,7 @@ export function parseArgs(argv) {
76
85
  env: environment ?? positionals[0] ?? 'dev',
77
86
  ...(port !== undefined ? { port } : {}),
78
87
  ...(host !== undefined ? { host } : {}),
88
+ ...(sessionFile !== undefined ? { sessionFile } : {}),
79
89
  };
80
90
  case 'deploy': {
81
91
  if (positionals.length > 1)
@@ -165,3 +175,10 @@ function requiredValue(flag, input) {
165
175
  throw new Error(`${flag} needs a value.`);
166
176
  return input;
167
177
  }
178
+ function sessionFilePath(input) {
179
+ const value = requiredValue('--session-file', input);
180
+ if (!isAbsolute(value) || normalize(value) !== value) {
181
+ throw new Error(`--session-file needs a normalized absolute path, got "${value}"`);
182
+ }
183
+ return value;
184
+ }
@@ -5,4 +5,5 @@ export declare function develop(input: {
5
5
  readonly projectDir: string;
6
6
  readonly environment: string;
7
7
  readonly overrides: DevelopmentOverrides;
8
+ readonly sessionFile?: string;
8
9
  }): Promise<number>;
@@ -3,15 +3,18 @@ import { mkdir, rm, rmdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { admitRuntime, compile, isDeployment, resolveRuntime, verifyPreflight, } from '../../../deployment/index.js';
6
+ import { addressing, assemble } from '../../../deployment/release/index.js';
6
7
  import { loadDeclaredSecrets } from '../dotenv.js';
7
8
  import { error, info } from '../log.js';
8
9
  import { DevelopmentLifecycle } from './lifecycle.js';
9
10
  import { acquireProjectLock } from './project-lock.js';
10
11
  import { LatestRebuild } from './rebuild.js';
12
+ import { DevelopmentSessionFileError, DevelopmentSessionFileWriter } from './session-file.js';
11
13
  import { watchSources } from './source-watch.js';
12
14
  /** Run one complete provider-neutral development session until interruption. */
13
15
  export async function develop(input) {
14
16
  const lifecycle = new DevelopmentLifecycle();
17
+ let sessionFile;
15
18
  let lock;
16
19
  let session;
17
20
  let sourceWatch;
@@ -20,6 +23,9 @@ export async function develop(input) {
20
23
  let failed = false;
21
24
  let watcherFailure;
22
25
  try {
26
+ if (input.sessionFile !== undefined) {
27
+ sessionFile = await DevelopmentSessionFileWriter.create(input.sessionFile);
28
+ }
23
29
  lock = await acquireProjectLock({
24
30
  projectRoot: input.projectDir,
25
31
  environment: input.environment,
@@ -27,6 +33,16 @@ export async function develop(input) {
27
33
  const runtimePath = await discoverRuntimePath(input.configPath);
28
34
  const builder = new ProjectBuilder({ ...input, runtimePath });
29
35
  let current = await builder.prepare(lifecycle.signal);
36
+ let sessionEvidence;
37
+ let placementReady = false;
38
+ let rebuildActive = false;
39
+ let updatePrevious;
40
+ let placementSequence = 0;
41
+ const publishSessionPhase = async () => {
42
+ if (sessionFile === undefined || sessionEvidence === undefined)
43
+ return;
44
+ await sessionFile.transition(placementReady ? (rebuildActive ? 'updating' : 'ready') : 'recovering', sessionEvidence);
45
+ };
30
46
  const adapter = current.adapter;
31
47
  const parameters = current.parameters;
32
48
  let restartReported = false;
@@ -38,14 +54,28 @@ export async function develop(input) {
38
54
  secrets: current.secrets,
39
55
  overrides: input.overrides,
40
56
  signal: lifecycle.signal,
57
+ async onRecovering() {
58
+ placementReady = false;
59
+ await publishSessionPhase();
60
+ },
41
61
  async onPlacement(placement) {
62
+ if (sessionFile !== undefined) {
63
+ sessionEvidence = coherentSessionEvidence(updatePrevious === undefined ? [current.artifact] : [current.artifact, updatePrevious], placement);
64
+ placementReady = true;
65
+ placementSequence += 1;
66
+ await publishSessionPhase();
67
+ }
42
68
  info(`Public: ${placement.releaseUrl}`);
43
69
  if (placement.localUrl !== undefined)
44
70
  info(`Runtime: ${placement.localUrl}`);
45
71
  },
46
72
  });
47
73
  rebuild = new LatestRebuild({
48
- prepare: (_ignored, signal) => builder.prepare(signal),
74
+ async prepare(_ignored, signal) {
75
+ rebuildActive = true;
76
+ await publishSessionPhase();
77
+ return builder.prepare(signal);
78
+ },
49
79
  async commit(candidate) {
50
80
  if (candidate.adapter.name !== adapter.name ||
51
81
  candidate.adapter.version !== adapter.version ||
@@ -54,19 +84,50 @@ export async function develop(input) {
54
84
  throw new Error('Adapter configuration changed; restart pnpm dev to apply it.');
55
85
  }
56
86
  const previous = current;
87
+ const beforePlacement = placementSequence;
88
+ updatePrevious = previous.artifact;
57
89
  current = candidate;
58
90
  try {
59
91
  await session.update({ artifact: candidate.artifact, secrets: candidate.secrets });
92
+ if (sessionFile !== undefined) {
93
+ if (placementSequence === beforePlacement || sessionEvidence === undefined) {
94
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter completed an update without reporting installed Release evidence.');
95
+ }
96
+ assertEvidenceMatches(candidate.artifact, sessionEvidence);
97
+ }
98
+ rebuildActive = false;
99
+ await publishSessionPhase();
60
100
  sourceWatch?.update(candidate.sources);
61
101
  info(`Updated ${candidate.artifact.build.schema.compiled.root.revision}.`);
62
102
  }
63
103
  catch (cause) {
64
104
  current = previous;
105
+ if (sessionFile !== undefined &&
106
+ sessionEvidence !== undefined &&
107
+ !evidenceMatches(previous.artifact, sessionEvidence)) {
108
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter rejected an update without restoring prior installed Release evidence.', { cause });
109
+ }
65
110
  throw cause;
66
111
  }
112
+ finally {
113
+ updatePrevious = undefined;
114
+ }
67
115
  },
68
- failed(cause) {
116
+ async failed(cause) {
117
+ rebuildActive = false;
118
+ if (cause instanceof DevelopmentSessionFileError) {
119
+ watcherFailure = cause;
120
+ lifecycle.stop(cause);
121
+ return;
122
+ }
69
123
  error(cause instanceof Error ? cause.message : String(cause));
124
+ try {
125
+ await publishSessionPhase();
126
+ }
127
+ catch (sessionCause) {
128
+ watcherFailure = sessionCause;
129
+ lifecycle.stop(sessionCause);
130
+ }
70
131
  },
71
132
  });
72
133
  sourceWatch = watchSources({
@@ -103,6 +164,13 @@ export async function develop(input) {
103
164
  }
104
165
  lifecycle.stop(failure);
105
166
  const cleanup = [];
167
+ try {
168
+ await sessionFile?.transition('stopping');
169
+ }
170
+ catch (cause) {
171
+ failed = true;
172
+ failure ??= cause;
173
+ }
106
174
  for (const operation of [
107
175
  () => sourceWatch?.stop(),
108
176
  () => rebuild?.stop(failure),
@@ -133,6 +201,12 @@ export async function develop(input) {
133
201
  cleanup.push(cause);
134
202
  }
135
203
  }
204
+ try {
205
+ await sessionFile?.transition(failed || cleanup.length > 0 ? 'failed' : 'stopped');
206
+ }
207
+ catch (cause) {
208
+ cleanup.push(cause);
209
+ }
136
210
  lifecycle.dispose();
137
211
  if (failed) {
138
212
  if (cleanup.length > 0)
@@ -143,6 +217,45 @@ export async function develop(input) {
143
217
  throw new AggregateError(cleanup, 'Development cleanup failed.');
144
218
  return 0;
145
219
  }
220
+ function coherentSessionEvidence(artifacts, placement) {
221
+ const evidence = placement.installedRelease;
222
+ if (evidence === undefined) {
223
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', '--session-file requires an adapter that reports exact installed Release evidence.');
224
+ }
225
+ let issuer;
226
+ try {
227
+ issuer = new URL(placement.releaseUrl).origin;
228
+ }
229
+ catch (cause) {
230
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter reported an invalid Release URL.', { cause });
231
+ }
232
+ if (placement.releaseUrl !== issuer) {
233
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter reported a non-canonical Release URL.');
234
+ }
235
+ let matches = false;
236
+ try {
237
+ matches = artifacts.some((artifact) => evidenceMatches(artifact, evidence));
238
+ }
239
+ catch (cause) {
240
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter reported invalid installed Release evidence.', { cause });
241
+ }
242
+ if (evidence.issuer !== issuer || !matches) {
243
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter installed Release evidence does not match the current artifact and placement.');
244
+ }
245
+ return evidence;
246
+ }
247
+ function assertEvidenceMatches(artifact, evidence) {
248
+ if (!evidenceMatches(artifact, evidence)) {
249
+ throw new DevelopmentSessionFileError('SESSION_FILE_EVIDENCE_INCOHERENT', 'Development adapter installed Release evidence does not match the committed artifact.');
250
+ }
251
+ }
252
+ function evidenceMatches(artifact, evidence) {
253
+ const root = artifact.build.schema.compiled.root;
254
+ const release = assemble(artifact.build, addressing(evidence.issuer));
255
+ return (evidence.origin === root.origin &&
256
+ evidence.revision === root.revision &&
257
+ evidence.etag === release.publication.etag);
258
+ }
146
259
  class ProjectBuilder {
147
260
  #configPath;
148
261
  #projectDir;
@@ -1,7 +1,7 @@
1
1
  export interface RebuildOperations<Input, Candidate> {
2
2
  prepare(input: Input, signal: AbortSignal): Promise<Candidate>;
3
3
  commit(candidate: Candidate, signal: AbortSignal): Promise<void>;
4
- failed(cause: unknown): void;
4
+ failed(cause: unknown): void | Promise<void>;
5
5
  }
6
6
  /** Serialize rebuilds while retaining only the newest pending invalidation. */
7
7
  export declare class LatestRebuild<Input, Candidate> {
@@ -41,7 +41,7 @@ export class LatestRebuild {
41
41
  }
42
42
  catch (cause) {
43
43
  if (!controller.signal.aborted)
44
- this.#operations.failed(cause);
44
+ await this.#operations.failed(cause);
45
45
  }
46
46
  finally {
47
47
  if (this.#current === controller)
@@ -0,0 +1,53 @@
1
+ export declare const DEVELOPMENT_SESSION_FILE_VERSION: 1;
2
+ export type DevelopmentSessionPhaseV1 = 'starting' | 'ready' | 'updating' | 'recovering' | 'stopping' | 'stopped' | 'failed';
3
+ export interface DevelopmentSessionProjectV1 {
4
+ readonly origin: string;
5
+ }
6
+ export interface DevelopmentSessionReleaseV1 {
7
+ readonly revision: string;
8
+ readonly issuer: string;
9
+ readonly etag: string;
10
+ readonly generation: string;
11
+ }
12
+ interface DevelopmentSessionFileBaseV1 {
13
+ readonly version: typeof DEVELOPMENT_SESSION_FILE_VERSION;
14
+ readonly sessionId: string;
15
+ readonly sequence: number;
16
+ readonly phase: DevelopmentSessionPhaseV1;
17
+ }
18
+ interface DevelopmentSessionStartingFileV1 extends DevelopmentSessionFileBaseV1 {
19
+ readonly phase: 'starting';
20
+ }
21
+ interface DevelopmentSessionActiveFileV1 extends DevelopmentSessionFileBaseV1 {
22
+ readonly phase: 'ready' | 'updating' | 'recovering';
23
+ readonly project: DevelopmentSessionProjectV1;
24
+ readonly release: DevelopmentSessionReleaseV1;
25
+ }
26
+ interface DevelopmentSessionTerminalFileBaseV1 extends DevelopmentSessionFileBaseV1 {
27
+ readonly phase: 'stopping' | 'stopped' | 'failed';
28
+ }
29
+ type DevelopmentSessionTerminalFileV1 = DevelopmentSessionTerminalFileBaseV1 & ({
30
+ readonly project: DevelopmentSessionProjectV1;
31
+ readonly release: DevelopmentSessionReleaseV1;
32
+ } | {
33
+ readonly project?: never;
34
+ readonly release?: never;
35
+ });
36
+ export type DevelopmentSessionFileV1 = DevelopmentSessionStartingFileV1 | DevelopmentSessionActiveFileV1 | DevelopmentSessionTerminalFileV1;
37
+ export interface DevelopmentSessionReleaseEvidenceV1 extends DevelopmentSessionProjectV1, DevelopmentSessionReleaseV1 {
38
+ }
39
+ export declare class DevelopmentSessionFileError extends Error {
40
+ readonly code: 'INVALID_SESSION_FILE_PATH' | 'SESSION_FILE_PATH_UNSAFE' | 'SESSION_FILE_EXISTS' | 'SESSION_FILE_TAMPERED' | 'SESSION_FILE_TRANSITION_INVALID' | 'SESSION_FILE_EVIDENCE_INCOHERENT';
41
+ readonly name = "DevelopmentSessionFileError";
42
+ constructor(code: 'INVALID_SESSION_FILE_PATH' | 'SESSION_FILE_PATH_UNSAFE' | 'SESSION_FILE_EXISTS' | 'SESSION_FILE_TAMPERED' | 'SESSION_FILE_TRANSITION_INVALID' | 'SESSION_FILE_EVIDENCE_INCOHERENT', message: string, options?: ErrorOptions);
43
+ }
44
+ export declare function acceptDevelopmentSessionFileV1(input: unknown): DevelopmentSessionFileV1;
45
+ /** SDK-private sole writer for one caller-selected machine-integration file. */
46
+ export declare class DevelopmentSessionFileWriter {
47
+ #private;
48
+ private constructor();
49
+ static create(path: string): Promise<DevelopmentSessionFileWriter>;
50
+ transition(phase: Exclude<DevelopmentSessionPhaseV1, 'starting'>, evidence?: DevelopmentSessionReleaseEvidenceV1): Promise<DevelopmentSessionFileV1>;
51
+ }
52
+ export declare function developmentSessionTransitionAllowed(previous: DevelopmentSessionPhaseV1, next: Exclude<DevelopmentSessionPhaseV1, 'starting'>): boolean;
53
+ export {};