@sequenceholdings/studio-cli 0.1.12 → 0.1.13

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/README.md CHANGED
@@ -97,13 +97,15 @@ You can always add or override environments yourself in
97
97
  `~/.config/lattice/config.toml` (user entries win over discovered ones):
98
98
 
99
99
  ```toml
100
+ # Must come before any [env.*] table — TOML attaches bare keys to the
101
+ # preceding table, so a trailing default_env is silently ignored.
102
+ default_env = "local"
103
+
100
104
  [env.local]
101
105
  url = "http://localhost:5001"
102
106
 
103
107
  [env.my-atlas]
104
108
  url = "https://atlas.example.com"
105
-
106
- default_env = "local"
107
109
  ```
108
110
 
109
111
  Pass `--env <name>` (or `-e <name>`) on commands that talk to the platform.
@@ -121,6 +123,7 @@ the artifact folder's `.artifact-studio/config.json` `defaultEnv` (set by
121
123
  | `seq-studio process lint` | Static checks (graph, return contracts, agent schema, timeouts) |
122
124
  | `seq-studio process plan -e <env>` | Build bundle, diff against currently-active version |
123
125
  | `seq-studio process apply -e <env> [--only <id1,id2>]` | Build → register bundle → promote each process. `--only` promotes just the named process ids (the bundle still contains the whole root — registration is inert) |
126
+ | `seq-studio process apply --repo processes/<name> [--ref <r>] -e <env> [--only <id1,id2>]` | Same as above, but materializes the source from a platform git-service repo. Pinned commit SHA is injected as bundle provenance. Requires `ATLAS_GIT_PAT` (or `AUTH0_M2M_CLIENT_SECRET` for CI) |
124
127
  | `seq-studio process test -e <env>` | CI wrapper: lint + plan, non-zero exit on errors or BREAKING diffs |
125
128
  | `seq-studio process simulate <id>` | In-process walk with stubbed runners (offline) |
126
129
  | `seq-studio process bundle build [-o file.json]` | Build a bundle locally |
@@ -129,6 +132,25 @@ the artifact folder's `.artifact-studio/config.json` `defaultEnv` (set by
129
132
  | `seq-studio process bundle list [-e <env>] [--limit N] [--cursor <hash>]` | List registered bundles (paginated; CLI auto-fetches all pages) |
130
133
  | `seq-studio process bundle publish <hash or bundle.json> [-e <env>]` | Register a local bundle (no promote) |
131
134
 
135
+ ### Remote source for `apply`
136
+
137
+ `apply` can deploy a process from the platform git service instead of a local
138
+ checkout:
139
+
140
+ ```bash
141
+ seq-studio process apply --repo processes/my-process -e staging
142
+ seq-studio process apply --repo processes/my-process --ref v1.2.0 -e production
143
+ ```
144
+
145
+ The source is materialized to a temp dir, dependencies are installed from the
146
+ committed `pnpm-lock.yaml` (must be Chainguard-resolved), process definitions
147
+ are discovered, the bundle is built with the pinned commit as provenance, and the
148
+ temp tree is cleaned up — even on error. Only the `processes` namespace is
149
+ accepted; other namespaces (artifacts, managed-functions) are rejected.
150
+
151
+ Auth: same as `artifact deploy --repo` — `ATLAS_GIT_PAT` for the smart-HTTP
152
+ clone path, or `AUTH0_M2M_CLIENT_SECRET` for CI's JSON materialize path.
153
+
132
154
  ### Process discovery
133
155
 
134
156
  `seq-studio` walks the current working directory for any subfolder
package/dist/config.d.ts CHANGED
@@ -38,6 +38,16 @@ export interface ResolvedEnv {
38
38
  name: string;
39
39
  url: string;
40
40
  }
41
+ /**
42
+ * Copy-pasteable fallback when the discovery catalog is unavailable.
43
+ *
44
+ * The `[env.*]` table is safe to append to an existing config.toml. We
45
+ * intentionally omit `default_env` from the snippet — appending it after
46
+ * an existing `[env.*]` table would scope it into that table (TOML bare-key
47
+ * rule) and silently no-op. Callers who want a default should put
48
+ * `default_env = "..."` at the top of the file, before any table.
49
+ */
50
+ export declare function manualEnvConfigHint(): string;
41
51
  /**
42
52
  * Resolve `--env <name>` against the effective config. Falls back to the
43
53
  * config's `defaultEnv` when no name is passed. Throws a clear error when
package/dist/config.js CHANGED
@@ -82,18 +82,40 @@ export async function writeConfig(config) {
82
82
  // smol-toml's stringify can't accept arbitrary nested keys via the
83
83
  // `env.<name>` syntax directly; we build it manually for stable
84
84
  // ordering and clean output.
85
- const lines = [];
85
+ //
86
+ // `default_env` MUST come before any `[env.*]` table. In TOML, a bare
87
+ // key after a table header belongs to that table — putting it last
88
+ // would silently attach it to the final `[env.*]` and `readConfig`
89
+ // (which only reads top-level `default_env`) would ignore it.
90
+ const lines = [`default_env = "${config.defaultEnv}"`, ''];
86
91
  for (const [name, value] of Object.entries(config.envs)) {
87
92
  lines.push(`[env.${name}]`);
88
93
  lines.push(`url = ${stringifyToml({ url: value.url }).trim().replace(/^url = /, '')}`);
89
94
  lines.push('');
90
95
  }
91
- lines.push(`default_env = "${config.defaultEnv}"`);
92
96
  await writeFile(path, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
93
97
  }
94
98
  /** Shared hint appended to visibility errors at the anonymous/partner tiers. */
95
99
  const DISCOVERY_HINT = 'If you expect more environments, authenticate (seq-studio login, or set ' +
96
100
  'AUTH0_M2M_CLIENT_SECRET) and run: seq-studio envs refresh';
101
+ /**
102
+ * Copy-pasteable fallback when the discovery catalog is unavailable.
103
+ *
104
+ * The `[env.*]` table is safe to append to an existing config.toml. We
105
+ * intentionally omit `default_env` from the snippet — appending it after
106
+ * an existing `[env.*]` table would scope it into that table (TOML bare-key
107
+ * rule) and silently no-op. Callers who want a default should put
108
+ * `default_env = "..."` at the top of the file, before any table.
109
+ */
110
+ export function manualEnvConfigHint() {
111
+ return (`Or add an environment manually in ${configPath()} (append-safe):\n` +
112
+ `\n` +
113
+ ` [env.my-env]\n` +
114
+ ` url = "https://your-atlas-host.example.com"\n` +
115
+ `\n` +
116
+ `To make it the default, put \`default_env = "my-env"\` at the TOP of ` +
117
+ `that file — before any [env.*] table.`);
118
+ }
97
119
  /**
98
120
  * Resolve `--env <name>` against the effective config. Falls back to the
99
121
  * config's `defaultEnv` when no name is passed. Throws a clear error when
@@ -1,4 +1,5 @@
1
- import { readConfig, configPath } from '../config.js';
1
+ import { tryGetAccessToken } from '../auth.js';
2
+ import { readConfig, configPath, manualEnvConfigHint } from '../config.js';
2
3
  import { bootstrapUrl, fetchCatalog, readCachedCatalog, } from '../env-catalog.js';
3
4
  const ENVS_USAGE = `usage:
4
5
  seq-studio envs list show the environments visible to your identity
@@ -30,6 +31,24 @@ export async function runEnvsCommand(sub, _rest) {
30
31
  return 1;
31
32
  }
32
33
  }
34
+ /**
35
+ * Tier `anonymous` means "no catalog cached", not necessarily "no token".
36
+ * Shared by list + refresh so they don't contradict each other.
37
+ */
38
+ async function printNoCatalogGuidance(log = console.log) {
39
+ const token = await tryGetAccessToken();
40
+ log('');
41
+ if (token) {
42
+ log(`Logged in, but no environment catalog is cached against ${bootstrapUrl()} — ` +
43
+ 'only "local" (plus any config.toml entries) is visible. Run: seq-studio envs refresh');
44
+ }
45
+ else {
46
+ log('Not authenticated — only "local" is visible. Authenticate with ' +
47
+ '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
48
+ }
49
+ log('');
50
+ log(manualEnvConfigHint());
51
+ }
33
52
  async function listCommand() {
34
53
  const [config, catalog] = await Promise.all([readConfig(), readCachedCatalog()]);
35
54
  const discovered = new Set(catalog?.environments.map((env) => env.name) ?? []);
@@ -48,9 +67,7 @@ async function listCommand() {
48
67
  console.log(` ${name.padEnd(width)} ${url} (${source})`);
49
68
  }
50
69
  if (config.tier === 'anonymous') {
51
- console.log('');
52
- console.log('Not authenticated — only "local" is visible. Authenticate with ' +
53
- '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
70
+ await printNoCatalogGuidance();
54
71
  }
55
72
  return 0;
56
73
  }
@@ -61,12 +78,12 @@ async function refreshCommand() {
61
78
  }
62
79
  catch (err) {
63
80
  console.error(`envs refresh: FAIL — ${err instanceof Error ? err.message : String(err)}`);
81
+ console.error('');
82
+ console.error(manualEnvConfigHint());
64
83
  return 1;
65
84
  }
66
85
  if (!catalog) {
67
- console.log(`Not authenticated against ${bootstrapUrl()} — catalog cleared; only ` +
68
- '"local" (plus any config.toml entries) is visible.\n' +
69
- 'Authenticate with `seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET) and retry.');
86
+ await printNoCatalogGuidance();
70
87
  return 0;
71
88
  }
72
89
  console.log(`tier: ${catalog.tier} — ${catalog.environments.length} environment(s) cached.`);
package/dist/login.d.ts CHANGED
@@ -7,6 +7,12 @@ interface LoginOptions {
7
7
  }
8
8
  export declare function openSystemBrowser(authorizationUrl: string): Promise<void>;
9
9
  export declare function loginWithPkce({ fetchImpl, now, openBrowser, port, timeoutMs, }: LoginOptions): Promise<void>;
10
+ /**
11
+ * Best-effort catalog refresh after a successful login. Drop the old cache
12
+ * first so a prior identity's tier never lingers; discovery failures must not
13
+ * fail login. Exported for unit tests.
14
+ */
15
+ export declare function refreshCatalogAfterLogin(): Promise<void>;
10
16
  export declare function login(): Promise<void>;
11
17
  export declare function logout(): Promise<void>;
12
18
  export {};
package/dist/login.js CHANGED
@@ -5,7 +5,8 @@ import { spawn } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN, saveTokens, seqapiTokenPath, } from './auth.js';
8
- import { clearCatalog, fetchCatalog } from './env-catalog.js';
8
+ import { manualEnvConfigHint } from './config.js';
9
+ import { bootstrapUrl, clearCatalog, fetchCatalog } from './env-catalog.js';
9
10
  const DEFAULT_REDIRECT_PORT = 5099;
10
11
  const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
11
12
  function base64Url(input) {
@@ -193,25 +194,32 @@ export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBro
193
194
  expires_at: now() / 1_000 + tokens.expiresIn,
194
195
  });
195
196
  }
196
- export async function login() {
197
- await loginWithPkce({});
198
- console.log(`Authenticated. Tokens saved to ${seqapiTokenPath()}.`);
199
- // Refresh the environment catalog for the new identity immediately, so a
200
- // previously cached catalog from another identity (or tier) never lingers
201
- // past a login. Drop the old cache first — if the refresh below fails, the
202
- // prior identity's catalog must not survive the login either.
203
- // Best-effort: discovery being unreachable must not fail login.
197
+ /**
198
+ * Best-effort catalog refresh after a successful login. Drop the old cache
199
+ * first so a prior identity's tier never lingers; discovery failures must not
200
+ * fail login. Exported for unit tests.
201
+ */
202
+ export async function refreshCatalogAfterLogin() {
204
203
  await clearCatalog();
205
204
  try {
206
205
  const catalog = await fetchCatalog();
207
206
  if (catalog) {
208
207
  console.log(`Environment catalog refreshed (tier: ${catalog.tier}, ` +
209
208
  `${catalog.environments.length} environment(s)).`);
209
+ return;
210
210
  }
211
211
  }
212
212
  catch {
213
- console.log('Could not refresh the environment catalog run: seq-studio envs refresh');
213
+ // Network / non-auth discovery errors fall through to the same hint.
214
214
  }
215
+ console.log(`Could not load the environment catalog from ${bootstrapUrl()} — ` +
216
+ 'run: seq-studio envs refresh');
217
+ console.log(manualEnvConfigHint());
218
+ }
219
+ export async function login() {
220
+ await loginWithPkce({});
221
+ console.log(`Authenticated. Tokens saved to ${seqapiTokenPath()}.`);
222
+ await refreshCatalogAfterLogin();
215
223
  }
216
224
  /**
217
225
  * Pre-unification artifact-studio token file. `seq-studio artifact` commands
package/dist/main.js CHANGED
@@ -42,6 +42,7 @@ const PROCESS_USAGE = `usage:
42
42
  seq-studio process lint
43
43
  seq-studio process plan [-e <env>]
44
44
  seq-studio process apply [-e <env>] [--only <id1,id2,...>]
45
+ seq-studio process apply --repo processes/<name> [--ref <r>] -e <env> [--only <id1,id2,...>]
45
46
  seq-studio process promote <processId> --version <v> [-e <env>]
46
47
  seq-studio process pull <processId> [--version <v>] [-e <env>] [--out <dir>] [--no-children]
47
48
  seq-studio process test [-e <env>] [--offline]
@@ -5,4 +5,13 @@
5
5
  * `artifact`): resolve `--env` from `~/.config/lattice/config.toml`, set
6
6
  * SEQUENCE_ORM_BASE_URL, share the seqapi token, forward argv.
7
7
  */
8
+ /**
9
+ * Classify an ERR_MODULE_NOT_FOUND from importing `@sequenceholdings/orm/cli`:
10
+ * is the orm package itself absent, or is it present but a dependency (e.g.
11
+ * drizzle-orm) failed to resolve? Match the MISSING specifier (the quoted name
12
+ * after "Cannot find package/module"), never the whole message: a transitive
13
+ * failure's text includes the importer path (`.../node_modules/@sequenceholdings/orm/...`),
14
+ * so a substring check would misread a missing dep as the orm package itself.
15
+ */
16
+ export declare function isOrmPackageMissing(message: string): boolean;
8
17
  export declare function runOrmCommand(sub: string | undefined, rest: string[]): Promise<number>;
@@ -19,6 +19,18 @@ const ORM_USAGE = `usage:
19
19
  discovered after you authenticate).
20
20
  Authenticate with: seq-studio login
21
21
  `;
22
+ /**
23
+ * Classify an ERR_MODULE_NOT_FOUND from importing `@sequenceholdings/orm/cli`:
24
+ * is the orm package itself absent, or is it present but a dependency (e.g.
25
+ * drizzle-orm) failed to resolve? Match the MISSING specifier (the quoted name
26
+ * after "Cannot find package/module"), never the whole message: a transitive
27
+ * failure's text includes the importer path (`.../node_modules/@sequenceholdings/orm/...`),
28
+ * so a substring check would misread a missing dep as the orm package itself.
29
+ */
30
+ export function isOrmPackageMissing(message) {
31
+ const specifier = message.match(/Cannot find (?:package|module) '([^']+)'/)?.[1];
32
+ return specifier === '@sequenceholdings/orm' || (specifier?.startsWith('@sequenceholdings/orm/') ?? false);
33
+ }
22
34
  export async function runOrmCommand(sub, rest) {
23
35
  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
24
36
  console.log(ORM_USAGE);
@@ -42,8 +54,15 @@ export async function runOrmCommand(sub, rest) {
42
54
  }
43
55
  catch (err) {
44
56
  if (err.code === 'ERR_MODULE_NOT_FOUND') {
45
- console.error('seq-studio orm requires @sequenceholdings/orm, which is not included ' +
46
- 'in this installation. It is available to Sequence-internal setups only.');
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ if (isOrmPackageMissing(message)) {
59
+ console.error('seq-studio orm needs the @sequenceholdings/orm package. Install it alongside the CLI:\n' +
60
+ ' npm install @sequenceholdings/orm');
61
+ }
62
+ else {
63
+ console.error('@sequenceholdings/orm is installed but a dependency failed to load. Reinstall dependencies, then retry.\n' +
64
+ ` ${message}`);
65
+ }
47
66
  return 1;
48
67
  }
49
68
  throw err;
@@ -8,7 +8,14 @@ export type ResolveProcessPin = (processId: string, version?: string) => Promise
8
8
  version: string;
9
9
  bundleHash: string;
10
10
  } | null>;
11
- export declare function buildBundleFromProcesses(defs: readonly LoadedProcess[], options?: {
11
+ export interface BuildBundleOptions {
12
12
  resolveProcessPin?: ResolveProcessPin;
13
- }): Promise<LatticeBundle>;
13
+ /** Override git provenance instead of reading from the local working tree. */
14
+ provenance?: {
15
+ gitCommit: string | null;
16
+ gitBranch: string | null;
17
+ gitDirty: boolean | null;
18
+ };
19
+ }
20
+ export declare function buildBundleFromProcesses(defs: readonly LoadedProcess[], options?: BuildBundleOptions): Promise<LatticeBundle>;
14
21
  export declare function summarizeBundle(bundle: LatticeBundle): LatticeBundleSummary;
@@ -20,9 +20,12 @@ export async function buildBundleFromProcesses(defs, options) {
20
20
  return options.resolveProcessPin(processId, version);
21
21
  });
22
22
  }
23
+ const metadata = options?.provenance
24
+ ? collectGitMetadataWithOverrides(options.provenance)
25
+ : collectGitMetadata();
23
26
  // Manifest, hash, version, and co-located subprocess pins are all derived in
24
27
  // the SDK so a CLI-built bundle is byte-identical to a server-applied one.
25
- return finalizeBundleIdentity({ processes, metadata: collectGitMetadata() });
28
+ return finalizeBundleIdentity({ processes, metadata });
26
29
  }
27
30
  async function resolveSubprocessPinsInProcesses(processes, resolve) {
28
31
  const pins = [];
@@ -266,6 +269,11 @@ function serializeNodeMetadata(node) {
266
269
  on_branch_error: parallel.on_branch_error,
267
270
  join_source: toSource(parallel.join),
268
271
  };
272
+ // Serialized only when non-default ('eager') so existing barrier
273
+ // bundles keep their hashes; a missing field reads as 'barrier'.
274
+ if (parallel.join_mode === 'eager') {
275
+ pmeta.join_mode = 'eager';
276
+ }
269
277
  if (parallel.max_concurrency !== undefined) {
270
278
  pmeta.max_concurrency = parallel.max_concurrency;
271
279
  }
@@ -348,6 +356,15 @@ function collectGitMetadata() {
348
356
  git_dirty: get('git status --porcelain') !== '',
349
357
  };
350
358
  }
359
+ function collectGitMetadataWithOverrides(provenance) {
360
+ return {
361
+ created_at: new Date().toISOString(),
362
+ created_by: process.env['USER'] ?? null,
363
+ git_commit: provenance.gitCommit,
364
+ git_branch: provenance.gitBranch,
365
+ git_dirty: provenance.gitDirty ?? false,
366
+ };
367
+ }
351
368
  export function summarizeBundle(bundle) {
352
369
  return {
353
370
  name: bundle.manifest.bundle.name,
@@ -171,6 +171,8 @@ function emitNode(node, ctx) {
171
171
  par.supervisor_retry !== undefined ? ` supervisor_retry: ${pretty(par.supervisor_retry, 1)},` : null,
172
172
  bindings(m.bindings),
173
173
  ` on_branch_error: ${lit(par.on_branch_error)},`,
174
+ // Only 'eager' is ever serialized (barrier is the omitted default).
175
+ par.join_mode === 'eager' ? ` join_mode: ${lit('eager')},` : null,
174
176
  ` join: ${par.join_source},`,
175
177
  mapper('input', m.input_mapper_source),
176
178
  tail,
@@ -9,6 +9,25 @@ export interface ParsedArgs {
9
9
  flags: Record<string, string | true>;
10
10
  }
11
11
  export declare function parseArgs(rest: string[]): ParsedArgs;
12
+ export type ProcessSourceSpec = {
13
+ kind: 'local';
14
+ } | {
15
+ kind: 'git-service';
16
+ namespace: string;
17
+ name: string;
18
+ ref?: string;
19
+ };
20
+ /**
21
+ * Parse the process-apply source flags. `--repo processes/<name>` materializes
22
+ * from the platform git service; absent means local (cwd / LATTICE_PROCESSES_ROOT).
23
+ * `--git-url` is passed through to the underlying `resolveArtifactSource` when
24
+ * the repo flag is a full ns/name pair (future); today only git-service is
25
+ * supported.
26
+ *
27
+ * Process repos live exclusively in the `processes` namespace — other namespaces
28
+ * (artifacts, managed-functions) are rejected to avoid cross-kind accidents.
29
+ */
30
+ export declare function parseProcessSourceSpec(flags: Record<string, string | true>): ProcessSourceSpec;
12
31
  export declare function initCommand(args: ParsedArgs): Promise<number>;
13
32
  export declare function lintCommand(args: ParsedArgs): Promise<number>;
14
33
  export declare function planCommand(args: ParsedArgs): Promise<number>;
@@ -21,6 +21,8 @@ import { lintProcesses, formatLintResult } from './lint.js';
21
21
  import { diffBundleAgainstActive, formatPlanDiff, } from './plan-diff.js';
22
22
  import { simulateProcess, formatSimulateResult } from './simulate.js';
23
23
  import { buildAgentSchemaLoader } from './agent-loader.js';
24
+ import { resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
25
+ import { prepareProcessBuildRoot } from './repo-install.js';
24
26
  export function parseArgs(rest) {
25
27
  const positional = [];
26
28
  const flags = {};
@@ -106,6 +108,39 @@ async function getEnvAndToken(args) {
106
108
  }
107
109
  return { env, token };
108
110
  }
111
+ /**
112
+ * Parse the process-apply source flags. `--repo processes/<name>` materializes
113
+ * from the platform git service; absent means local (cwd / LATTICE_PROCESSES_ROOT).
114
+ * `--git-url` is passed through to the underlying `resolveArtifactSource` when
115
+ * the repo flag is a full ns/name pair (future); today only git-service is
116
+ * supported.
117
+ *
118
+ * Process repos live exclusively in the `processes` namespace — other namespaces
119
+ * (artifacts, managed-functions) are rejected to avoid cross-kind accidents.
120
+ */
121
+ export function parseProcessSourceSpec(flags) {
122
+ const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
123
+ const ref = typeof flags.ref === 'string' ? flags.ref : undefined;
124
+ if (flags.repo === true)
125
+ throw new Error('--repo requires a value: --repo processes/<name>');
126
+ if (flags.ref === true)
127
+ throw new Error('--ref requires a value.');
128
+ if (ref && !repo)
129
+ throw new Error('--ref only applies together with --repo.');
130
+ if (ref && ref.startsWith('-'))
131
+ throw new Error(`--ref must not start with "-" (got "${ref}").`);
132
+ if (!repo)
133
+ return { kind: 'local' };
134
+ const parts = repo.split('/');
135
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
136
+ throw new Error(`--repo must be "processes/<name>" (got "${repo}").`);
137
+ }
138
+ if (parts[0] !== 'processes') {
139
+ throw new Error(`Process deploys require the "processes" namespace — got "${parts[0]}". ` +
140
+ `Use --repo processes/<name>.`);
141
+ }
142
+ return { kind: 'git-service', namespace: parts[0], name: parts[1], ref };
143
+ }
109
144
  // ---------------------------------------------------------------------------
110
145
  // init
111
146
  // ---------------------------------------------------------------------------
@@ -199,18 +234,18 @@ async function tryBuildAgentLoader(args) {
199
234
  // ---------------------------------------------------------------------------
200
235
  // plan
201
236
  // ---------------------------------------------------------------------------
202
- async function buildBundleForPublish(args, defs) {
237
+ async function buildBundleForPublish(args, defs, provenance) {
203
238
  const hasSubprocess = defs.some((d) => d.process.nodes.some((n) => n.kind === 'subprocess'));
204
239
  try {
205
240
  const { env, token } = await getEnvAndToken(args);
206
241
  const resolveProcessPin = buildResolveProcessPinFromEnv(env, token);
207
- return await buildBundleFromProcesses(defs, { resolveProcessPin });
242
+ return await buildBundleFromProcesses(defs, { resolveProcessPin, provenance });
208
243
  }
209
244
  catch (err) {
210
245
  if (hasSubprocess) {
211
246
  throw new Error('subprocess nodes require -e <env> and `seq-studio login` to resolve child process versions', { cause: err });
212
247
  }
213
- return await buildBundleFromProcesses(defs);
248
+ return await buildBundleFromProcesses(defs, { provenance });
214
249
  }
215
250
  }
216
251
  export async function planCommand(args) {
@@ -334,8 +369,36 @@ export function resolveOnlyIds({ only, knownIds, }) {
334
369
  return { ids };
335
370
  }
336
371
  export async function applyCommand(args) {
372
+ const spec = parseProcessSourceSpec(args.flags);
373
+ if (spec.kind === 'git-service') {
374
+ return applyFromRepo(args, spec);
375
+ }
376
+ return applyLocal(args);
377
+ }
378
+ async function applyLocal(args) {
337
379
  const { env, token } = await getEnvAndToken(args);
338
380
  const defs = await loadProcessDefinitions();
381
+ return registerAndPromote({ args, env, token, defs });
382
+ }
383
+ async function applyFromRepo(args, spec) {
384
+ const { env, token } = await getEnvAndToken(args);
385
+ const source = await resolveArtifactSource({ kind: 'git-service', namespace: spec.namespace, name: spec.name, ref: spec.ref }, { baseUrl: env.url, token });
386
+ try {
387
+ await prepareProcessBuildRoot(source.dir);
388
+ const defs = await loadProcessDefinitions(source.dir);
389
+ return await registerAndPromote({
390
+ args,
391
+ env,
392
+ token,
393
+ defs,
394
+ provenance: source.provenance,
395
+ });
396
+ }
397
+ finally {
398
+ await source.cleanup();
399
+ }
400
+ }
401
+ async function registerAndPromote({ args, env, token, defs, provenance, }) {
339
402
  const onlyResult = resolveOnlyIds({
340
403
  only: args.flags.only,
341
404
  knownIds: new Set(defs.map((d) => d.process.id)),
@@ -357,7 +420,7 @@ export async function applyCommand(args) {
357
420
  console.error('\napply FAILED — lint rejected the bundle');
358
421
  return 1;
359
422
  }
360
- const bundle = await buildBundleForPublish(args, defs);
423
+ const bundle = await buildBundleForPublish(args, defs, provenance);
361
424
  console.log(`[seq-studio] built bundle ${bundle.bundle_hash.slice(0, 12)} ` +
362
425
  `(${bundle.processes.length} process(es))`);
363
426
  // We send only the bundle. `created_by` (and `promoted_by` for the
@@ -668,7 +731,7 @@ export async function doctorCommand(args) {
668
731
  lines.push(`config: ok — default_env=${config.defaultEnv}, envs=${Object.keys(config.envs).join(',')}`);
669
732
  lines.push(`tier: ${config.tier}` +
670
733
  (config.tier === 'anonymous'
671
- ? ' — authenticate and run `seq-studio envs refresh` to discover more environments'
734
+ ? ' — no environment catalog cached; run `seq-studio envs refresh` to discover more environments'
672
735
  : ''));
673
736
  }
674
737
  const requested = typeof args.flags.env === 'string' ? args.flags.env : undefined;
@@ -28,5 +28,8 @@ export declare function findProcessRoot(): Promise<string>;
28
28
  * Load every `<dir>/process.ts` under the configured root. Each module
29
29
  * must `export default defineProcess(...)`. Process ids must be unique
30
30
  * across the set — duplicates throw.
31
+ *
32
+ * @param explicitRoot When supplied, overrides LATTICE_PROCESSES_ROOT and cwd.
33
+ * Used by `--repo` deploys to point discovery at the materialized temp tree.
31
34
  */
32
- export declare function loadProcessDefinitions(): Promise<readonly LoadedProcess[]>;
35
+ export declare function loadProcessDefinitions(explicitRoot?: string): Promise<readonly LoadedProcess[]>;
@@ -55,9 +55,12 @@ export async function findProcessRoot() {
55
55
  * Load every `<dir>/process.ts` under the configured root. Each module
56
56
  * must `export default defineProcess(...)`. Process ids must be unique
57
57
  * across the set — duplicates throw.
58
+ *
59
+ * @param explicitRoot When supplied, overrides LATTICE_PROCESSES_ROOT and cwd.
60
+ * Used by `--repo` deploys to point discovery at the materialized temp tree.
58
61
  */
59
- export async function loadProcessDefinitions() {
60
- const root = await findProcessRoot();
62
+ export async function loadProcessDefinitions(explicitRoot) {
63
+ const root = explicitRoot ?? (await findProcessRoot());
61
64
  const entries = await readdir(root);
62
65
  const found = [];
63
66
  const seenIds = new Set();
@@ -402,17 +402,66 @@ async function lintAgent(processId, node, errors, warnings, loadAgentSchemaEdgeI
402
402
  });
403
403
  }
404
404
  }
405
+ /**
406
+ * Matches an undecided-sentinel return in a serialized join source: the
407
+ * literal `{ undecided: true }` (the correct authored form — reducers run in
408
+ * a vm with no module imports in scope) or a `JOIN_UNDECIDED` reference
409
+ * (recognized so the eager no-sentinel warning doesn't double-fire on top of
410
+ * the closure-scope warning that already flags the out-of-scope import).
411
+ */
412
+ const UNDECIDED_SENTINEL_RE = /\bundecided\s*:\s*true\b|\bJOIN_UNDECIDED\b/;
405
413
  /**
406
414
  * Lint a parallel node's `join` reducer the way we lint automation fns:
407
415
  * scan the serialized source for literal `edge_id: '…'` returns and confirm
408
416
  * each is in the parallel node's own outgoing_edges. The reducer chooses the
409
417
  * parallel node's exit edge, so a typo there is a routing bug the orchestrator
410
418
  * would only surface at run time.
419
+ *
420
+ * join_mode checks: a BARRIER join returning the `{undecided: true}` sentinel
421
+ * is an authoring error (the runner fails the block at runtime), and an EAGER
422
+ * join that never returns the sentinel decides on the first settlement —
423
+ * legal but suspicious, so it warns. No static engine check is possible for
424
+ * eager blocks: the execution engine is chosen per RUN (`{"engine": ...}` +
425
+ * environment rollout policy), not per process — the Trigger-only limitation
426
+ * is enforced at runtime (an eager block on a Temporal run fails with a clear
427
+ * unsupported error at branch resolution).
411
428
  */
412
429
  function lintParallelJoin(processId, node, errors, warnings) {
413
430
  const join = node.join;
414
431
  const source = typeof join === 'function' ? join.toString() : '';
415
432
  const outgoingIds = new Set(node.outgoing_edges.map((e) => e.id));
433
+ const joinMode = node.join_mode ?? 'barrier';
434
+ const returnsUndecided = UNDECIDED_SENTINEL_RE.test(source);
435
+ if (joinMode === 'eager') {
436
+ if (typeof join !== 'function') {
437
+ // defineParallelNode already rejects this; belt-and-suspenders for
438
+ // hand-built definitions that bypassed the SDK constructor.
439
+ errors.push({
440
+ process_id: processId,
441
+ node_id: node.id,
442
+ message: `join_mode 'eager' requires an explicit join reducer`,
443
+ });
444
+ }
445
+ if (!returnsUndecided) {
446
+ warnings.push({
447
+ process_id: processId,
448
+ node_id: node.id,
449
+ message: `join_mode is 'eager' but the join source never returns the ` +
450
+ `{undecided: true} sentinel — the block will decide on the FIRST ` +
451
+ `branch settlement; confirm that is intended`,
452
+ });
453
+ }
454
+ }
455
+ else if (returnsUndecided) {
456
+ errors.push({
457
+ process_id: processId,
458
+ node_id: node.id,
459
+ message: `parallel join returns the {undecided: true} sentinel but join_mode ` +
460
+ `is 'barrier' (the default) — the sentinel is only meaningful with ` +
461
+ `join_mode: 'eager'; at runtime the block FAILS when a barrier join ` +
462
+ `returns it`,
463
+ });
464
+ }
416
465
  const literals = new Set();
417
466
  for (const match of source.matchAll(EDGE_ID_LITERAL_RE)) {
418
467
  if (match[1])
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Remote-install helper for `process apply --repo`. Installs dependencies
3
+ * in a materialized process repo so `tsx` can import `process.ts` modules.
4
+ *
5
+ * Re-uses the artifact-studio trusted-install pipeline:
6
+ * 1. Require `package.json` and `pnpm-lock.yaml` (no floating installs)
7
+ * 2. Strip install-control files (.npmrc, pnpm-workspace.yaml, pnpm hooks)
8
+ * 3. Full trust validation: registry-only specs, no patches, no symlinks,
9
+ * Chainguard lockfile with repo/commit/directory/tarball checks
10
+ * 4. Run pinned pnpm with frozen lockfile, ignore-scripts, prod-only
11
+ */
12
+ /**
13
+ * Install dependencies in a materialized remote process repo. Process repos
14
+ * deployed from git-service must carry a `package.json` (process.ts files
15
+ * import `@sequenceholdings/lattice/define` and the module won't resolve
16
+ * without node_modules) and a Chainguard-resolved `pnpm-lock.yaml`.
17
+ *
18
+ * Throws when either file is missing or when the tree fails the full
19
+ * trusted-install validation.
20
+ */
21
+ export declare function prepareProcessBuildRoot(dir: string): Promise<void>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Remote-install helper for `process apply --repo`. Installs dependencies
3
+ * in a materialized process repo so `tsx` can import `process.ts` modules.
4
+ *
5
+ * Re-uses the artifact-studio trusted-install pipeline:
6
+ * 1. Require `package.json` and `pnpm-lock.yaml` (no floating installs)
7
+ * 2. Strip install-control files (.npmrc, pnpm-workspace.yaml, pnpm hooks)
8
+ * 3. Full trust validation: registry-only specs, no patches, no symlinks,
9
+ * Chainguard lockfile with repo/commit/directory/tarball checks
10
+ * 4. Run pinned pnpm with frozen lockfile, ignore-scripts, prod-only
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
+ import { homedir, tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { ARTIFACT_BUILD_PNPM, buildControlledArtifactNpmrc, } from '@sequenceholdings/artifact-studio/prepare-build';
18
+ import { stripInstallControlFiles } from '@sequenceholdings/artifact-studio/sanitize-remote-tree';
19
+ import { ArtifactInstallTrustError, assertTrustedArtifactInstallTree, } from '@sequenceholdings/artifact-studio/trusted-install';
20
+ class ProcessInstallError extends Error {
21
+ name = 'ProcessInstallError';
22
+ }
23
+ /**
24
+ * Install dependencies in a materialized remote process repo. Process repos
25
+ * deployed from git-service must carry a `package.json` (process.ts files
26
+ * import `@sequenceholdings/lattice/define` and the module won't resolve
27
+ * without node_modules) and a Chainguard-resolved `pnpm-lock.yaml`.
28
+ *
29
+ * Throws when either file is missing or when the tree fails the full
30
+ * trusted-install validation.
31
+ */
32
+ export async function prepareProcessBuildRoot(dir) {
33
+ if (!existsSync(join(dir, 'package.json'))) {
34
+ throw new ProcessInstallError('Remote process repos must include a package.json with @sequenceholdings/lattice as a dependency.');
35
+ }
36
+ if (!existsSync(join(dir, 'pnpm-lock.yaml'))) {
37
+ throw new ProcessInstallError('Remote process deploy requires pnpm-lock.yaml — run `pnpm install` locally and commit the lockfile.');
38
+ }
39
+ const stripped = stripInstallControlFiles(dir);
40
+ if (stripped.length > 0) {
41
+ console.warn(`[seq-studio] stripped install-control files from remote tree: ${stripped.join(', ')}`);
42
+ }
43
+ try {
44
+ assertTrustedArtifactInstallTree(dir);
45
+ }
46
+ catch (error) {
47
+ if (error instanceof ArtifactInstallTrustError) {
48
+ throw new ProcessInstallError(`Remote process repo is not trusted for install: ${error.message}`);
49
+ }
50
+ throw error;
51
+ }
52
+ await rm(join(dir, 'node_modules'), { recursive: true, force: true });
53
+ const configDir = await mkdtemp(join(tmpdir(), 'process-pnpm-'));
54
+ const controlledNpmrc = join(configDir, '.npmrc');
55
+ const userNpmrcPath = process.env.npm_config_userconfig ?? join(homedir(), '.npmrc');
56
+ console.log(`[seq-studio] installing process dependencies (frozen lockfile, prod only) via ${ARTIFACT_BUILD_PNPM}…`);
57
+ try {
58
+ await writeFile(controlledNpmrc, buildControlledArtifactNpmrc(userNpmrcPath), { mode: 0o600 });
59
+ const spawnOpts = {
60
+ cwd: configDir,
61
+ stdio: 'inherit',
62
+ env: { ...process.env, npm_config_userconfig: controlledNpmrc },
63
+ };
64
+ execFileSync('npx', [
65
+ '--yes',
66
+ ARTIFACT_BUILD_PNPM,
67
+ '--dir',
68
+ dir,
69
+ 'install',
70
+ '--frozen-lockfile',
71
+ '--ignore-scripts',
72
+ '--prod',
73
+ ], spawnOpts);
74
+ }
75
+ catch (error) {
76
+ const detail = error instanceof Error ? error.message : String(error);
77
+ throw new ProcessInstallError(`process dependency install failed (${ARTIFACT_BUILD_PNPM}). ` +
78
+ `Run \`pnpm install\` locally and commit pnpm-lock.yaml. (${detail})`);
79
+ }
80
+ finally {
81
+ await rm(configDir, { recursive: true, force: true });
82
+ }
83
+ }
@@ -10,7 +10,7 @@
10
10
  * - Stubs automation runner identically — native handlers live in the
11
11
  * Atlas image and can't execute here
12
12
  */
13
- import { DEFAULT_MAX_FANOUT_WIDTH, } from '@sequenceholdings/lattice/define';
13
+ import { DEFAULT_MAX_FANOUT_WIDTH, isJoinUndecided, } from '@sequenceholdings/lattice/define';
14
14
  import { extractOutputState, mergeRunState } from '@sequenceholdings/lattice';
15
15
  const DEFAULT_MAX_ITERATIONS = 1000;
16
16
  const DEFAULT_MAX_REVISITS = 100;
@@ -246,7 +246,20 @@ async function simulateParallel(args, node) {
246
246
  item: br.item,
247
247
  });
248
248
  }
249
+ // The simulator settles every branch before joining, so an EAGER join sees
250
+ // the all-settled case (its incremental undecided passes aren't modelled —
251
+ // simulate verifies topology/routing, not settlement timing). In that case
252
+ // the eager contract requires a decision; surface a sentinel return as the
253
+ // same clear error the runtime raises instead of a confusing edge failure.
249
254
  const joinOutput = node.join(results, args.ctx);
255
+ if (isJoinUndecided(joinOutput)) {
256
+ throw new Error(node.join_mode === 'eager'
257
+ ? `parallel node "${node.id}": eager join returned {undecided: true} with ` +
258
+ `every branch settled — an eager join must decide once nothing is pending`
259
+ : `parallel node "${node.id}": join returned the {undecided: true} sentinel ` +
260
+ `but join_mode is 'barrier' — the sentinel is only meaningful with ` +
261
+ `join_mode: 'eager'`);
262
+ }
250
263
  return foldBranchState(joinOutput, results);
251
264
  }
252
265
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Unified Sequence Studio CLI — `seq-studio process` (Lattice), `seq-studio artifact` (Artifact Studio), `seq-studio functions` / `secrets`, `seq-studio repos` (platform git-service), and `seq-studio auth pat` (git-service PATs). Includes Auth0 browser login shared with seqapi.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "smol-toml": "^1.4.2",
41
41
  "tsx": "^4.20.3",
42
42
  "zod": "^4.1.13",
43
- "@sequenceholdings/artifact-studio": "0.1.11",
44
- "@sequenceholdings/lattice": "0.1.1"
43
+ "@sequenceholdings/artifact-studio": "0.1.12",
44
+ "@sequenceholdings/lattice": "0.1.2"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@sequenceholdings/orm": "0.1.1"