Skip to content

Expose app dev session files for agents - #8926

Open
isaacroldan wants to merge 14 commits into
mainfrom
make-dev-agentic
Open

isaacroldan wants to merge 14 commits into
mainfrom
make-dev-agentic

Conversation

@isaacroldan

@isaacroldan isaacroldan commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

Agents need to find an existing app dev session and read its current status and output from another terminal. This PR builds on the reusable CLI Kit API in #8952.

WHAT is this pull request doing?

Register one session per app configuration, capture process output in JSONL, and save the CLI's current preview status and errors. Non-interactive sessions remain registered until they stop, and dev work stops if session ownership or output fails. Active dev sessions handle signal cancellation and drain their logs before exit. Function compilation uses the supplied streams so Javy errors appear in both terminal output and the session log.

The withDevSession service maps app metadata to the shared withDevInstance helper in #8952. The command passes app work to that service; there is no session class or command-owned acquisition logic. The app integration uses the shared tee API from #8952. The session files include the PID, session ID, URLs, log path, and details.preview. Preview status reflects the CLI's reported state. The agent workflow and integration tests are included here; file ownership, logging, and their unit tests are in #8952.

How to manually test your changes?

  1. Start shopify app dev for a configured app and development store. From another terminal, read .shopify/dev/*.json and the reported logFile.
  2. Start dev again with the same configuration and confirm it reports the existing session.
  3. Introduce an invalid extension import. Check details.preview.state and errors, then correct the import and check the new output and status.
  4. Stop dev and restart with --no-input. Confirm the session ID changes, the log starts fresh, and the session remains registered while dev runs. Press Ctrl+C once and confirm that the process exits and the instance record is removed. Repeat with SIGTERM.

See docs/cli/app-dev-agent-workflow.md for the file-reading workflow.

Validation

  • CI repair: dev tees and their tests use the merged CLI Kit API without the removed stream option. Source-loaded shutdown subprocesses have a 60-second overall allowance, a 20-second deadline from signal dispatch, and the existing 5-second postrun exit check. An unbuilt-checkout probe with a simulated 22-second startup fails before the change and passes afterward; a deliberately stuck shutdown still fails through the child watchdog. The 59-test focused suite and the full workspace build pass locally.

  • After adopting the scoped helper, all 59 focused tests for this PR pass. The updated stack passes 158 tests, including output-context and preview-process coverage. SIGINT subprocess tests in text and JSON mode also pass on Node 22.12.0.

  • Non-interactive text output now adds a newline to complete CLI string messages. Existing LF/CRLF endings and subprocess byte chunks are preserved. The regression reproduced joined polling warnings before the fix and covers both output streams, empty writes, explicit string encodings, split UTF-8, and write completion. All 16 focused UI/service/shutdown tests for this fix pass on this PR.

This PR independently passes 59 focused tests covering app-dev command/service/UI behavior, real-bootstrap shutdown, and function builds. App and CLI type checks and focused lint pass. Neither this PR nor #8957 changes dev-instance.ts or its unit tests.

  • Non-interactive shutdown now uses the same postrun exit helper as the interactive UI. The subprocess regression leaves a background timer active; before this fix, the process stayed alive after cancellation, log flushing, and session cleanup. One signal now exits after the real postrun hook completes.
  • The signal regressions import the real CLI bootstrap, dev renderer, and session service in a subprocess. For both SIGINT and SIGTERM, all 1,000 queued lines plus the final cancellation message reach the log before the process exits successfully. The instance record and lock are removed. Separate cases verify the default exit when no dev session is running. These POSIX signal tests skip Windows.
  • A compiler fixture runs a real failing Node subprocess through runJavy and the dev tee streams. Its diagnostic reaches both the terminal sink and session JSONL. The signal and compiler regressions failed before their fixes.
  • The updated JSON stack passes 110 focused tests. Single-SIGINT cases also pass on Node 22.12.0 in both text and JSON modes. A rebuilt-CLI probe loads the real bundled bootstrap and verifies 5,104 identical stdout/file records after SIGTERM, including a slow reader, ordered output, split UTF-8, late output, and a final partial line. Exit code is zero; lock cleanup and restart recovery pass.

The shutdown subprocess tests also pass from a clean checkout with no generated workspace modules on Node 22.12.0, 24.1.0, and 26.1.0. Their source loader and test TypeScript config resolve workspace packages directly. This reproduces the unit-test CI setup and keeps normal type checking enabled in the child processes. The 58-test focused suite passes with CI environment variables and forked workers.

Stress testing

The larger local session-API checks used real temporary files and separate child processes:

  • Non-interactive lifetime: let a watcher action finish setup while keeping its service alive. The session remained discoverable until cancellation; a second start reported the existing session.
  • Shutdown with pending work: queued 30,000 complete log lines, a final partial UTF-8 line, and 500 status updates, then sent SIGINT or SIGTERM. Both runs retained all 30,001 records, exited successfully, and removed the status file and lock.
  • Terminal/log parity: sent 20,000 alternating extension-source messages through a delayed output stream, then exercised the real DevSessionLogger and extension WebSocket log handler. All 20,003 records had the expected message and source. Messages without a trailing newline appeared separately; byte output still assembled split UTF-8 into complete lines.
  • Slow reader: blocked a child process's stdout pipe. A producer respecting backpressure paused, then delivered all 20,000 records when reading resumed.
  • Status history: integration tests checked the initial status and ready/updating/error transitions. URL-only changes did not repeat status messages, and the latest status/errors remained available in the snapshot.
  • Actual local executable: rebuilt the bundle, checked pnpm shopify app dev --help, and probed the generated chunk loaded by app dev. Complete messages stayed separate, split UTF-8 was preserved, and JSONL records omitted session IDs.

The ownership races, SIGKILL recovery, 33-second pause/resume, 3,000-update pressure test, 100,001-record burst, and filesystem-error matrix are detailed in the base PR, #8952. Regression cases are covered by committed tests; the larger stress loops were local, ad hoc scripts and are not part of CI.

These checks ran on macOS arm64 with Node v26.1.0. The stress harnesses did not exercise Windows/Linux or remote Shopify update behavior.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes
  • I've considered analytics changes to measure impact
  • Added a minor changeset for the app-dev behavior

@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Oct 15, 2026
@isaacroldan

Copy link
Copy Markdown
Contributor Author

/snapit

@github-actions

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm.

Built from 710d4b4673dfebeba3ab9e6e08046905197986fc. Workflow run.

Test the snapshot by installing your package globally:

pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20261015153801

Caution

After installing, validate the version by running shopify version in your terminal.
If the versions don't match, you might have multiple global instances installed.
Use which shopify to find out which one you are running and uninstall it.

@isaacroldan

Copy link
Copy Markdown
Contributor Author

/snapit

@github-actions

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm.

Built from 2b3b7ddf9a2ee813a1955a702160239f123499b9. Workflow run.

Test the snapshot by installing your package globally:

pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20261019115812

Caution

After installing, validate the version by running shopify version in your terminal.
If the versions don't match, you might have multiple global instances installed.
Use which shopify to find out which one you are running and uninstall it.

@isaacroldan
isaacroldan force-pushed the make-dev-agentic branch 3 times, most recently from cfd91fb to b06cf68 Compare October 19, 2026 15:40
@isaacroldan isaacroldan changed the title Add a reusable dev-instance lock and JSONL log, and use it in app dev Expose app dev session files for agents Oct 19, 2026
@isaacroldan
isaacroldan changed the base branch from main to codex/dev-instance-support October 19, 2026 15:41
@isaacroldan
isaacroldan added this pull request to stack #8953 October 19, 2026 15:41
@isaacroldan

Copy link
Copy Markdown
Contributor Author

/snapit

@github-actions

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm.

Built from b06cf681d7a1a60c17630b5967ccc46ee43b213d. Workflow run.

Test the snapshot by installing your package globally:

pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20261019155212

Caution

After installing, validate the version by running shopify version in your terminal.
If the versions don't match, you might have multiple global instances installed.
Use which shopify to find out which one you are running and uninstall it.

@github-actions github-actions Bot added Area: @shopify/app @shopify/app package issues and removed Area: @shopify/cli @shopify/cli package issues labels Oct 19, 2026
@isaacroldan isaacroldan mentioned this pull request Oct 20, 2026
1 of 4 tasks
@isaacroldan
isaacroldan marked this pull request as ready for review October 20, 2026 16:18
@isaacroldan
isaacroldan requested a review from a team as a code owner October 20, 2026 16:18
@github-actions

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/dev-instance-output.d.ts
import { zod } from '../../public/node/schema.js';
import { Writable, type WritableOptions } from 'stream';
export declare const devInstanceLogOutputSchema: import("../../public/node/json-output-schema.js").JsonOutputSchema<zod.ZodObject<{
    timestamp: zod.ZodString;
    source: zod.ZodString;
    level: zod.ZodOptional<zod.ZodEnum<["debug", "info", "warning", "error"]>>;
    message: zod.ZodString;
}, "strict", zod.ZodTypeAny, {
    source: string;
    message: string;
    timestamp: string;
    level?: "info" | "error" | "debug" | "warning" | undefined;
}, {
    source: string;
    message: string;
    timestamp: string;
    level?: "info" | "error" | "debug" | "warning" | undefined;
}>>;
/** One line of a dev instance's JSON Lines log. */
export type DevInstanceLogEntry = zod.infer<typeof devInstanceLogOutputSchema.schema>;
interface DevInstanceOutputOptions {
    logFile: string;
    logOutput?: Writable;
    isActive: () => boolean;
    onError: (error: unknown) => void;
}
export declare function createDevInstanceOutput({ logFile, logOutput, isActive, onError }: DevInstanceOutputOptions): {
    opened: Promise<any[]>;
    log: (entry: Omit<DevInstanceLogEntry, "timestamp"> & {
        timestamp?: string;
    }) => Promise<void>;
    tee: (target: Writable, { source, stringWritesAreMessages }: {
        source: string;
        stringWritesAreMessages?: boolean;
    }) => DevInstanceOutput;
    flush: () => Promise<PromiseSettledResult<void>[]>;
    close: () => Promise<void>;
};
declare class DevInstanceOutput extends Writable {
    private readonly contexts;
    constructor(options: WritableOptions & {
        write: NonNullable<WritableOptions['write']>;
    });
    write(chunk: string | Uint8Array, encoding?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean;
    end(chunk?: string | Uint8Array | null | (() => void), encoding?: BufferEncoding | (() => void), callback?: () => void): this;
}
export {};
packages/cli-kit/dist/private/node/output-context.d.ts
export interface ConcurrentOutputContext {
    outputPrefix?: string;
    /** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */
    stripAnsi?: boolean;
}
export declare function useConcurrentOutputContext<T>(context: ConcurrentOutputContext, callback: () => T): T;
/** Returns the source and styling context for the current output write. */
export declare function getConcurrentOutputContext(): ConcurrentOutputContext | undefined;
packages/cli-kit/dist/public/node/dev-instance.d.ts
import { type DevInstanceLogEntry } from '../../private/node/dev-instance-output.js';
import { Writable } from 'stream';
/**
 * A stable error code for JSON consumers when a long-running command refuses to start because another instance of
 * it is already running for the same project.
 */
export declare const DEV_INSTANCE_ALREADY_RUNNING_CODE = "dev_instance_already_running";
/**
 * Describes a running instance of a long-running command such as `app dev` or `theme dev`.
 * It's written to `.shopify/dev/<slug>.json` so agents and other tools can discover the instance.
 */
export interface DevInstanceInfo {
    schemaVersion: 1;
    /** Identifies this run, including when the OS reuses a PID. */
    sessionId: string;
    /** The command that owns the instance, for example `app dev`. */
    command: string;
    /** Distinguishes instances of the same command in the same directory, for example the app configuration file. */
    key?: string;
    pid: number;
    status: 'starting' | 'running';
    startedAt: string;
    updatedAt: string;
    /** The project directory the instance runs for. */
    directory: string;
    /** The JSON Lines file the instance appends its output to. */
    logFile: string;
    /** Command-specific, JSON-serializable details such as URLs and ports. */
    details: {
        [key: string]: unknown;
    };
}
export { devInstanceLogOutputSchema, type DevInstanceLogEntry } from '../../private/node/dev-instance-output.js';
export interface DevInstancePaths {
    /** The folder that holds every dev instance of the project: `.shopify/dev`. */
    folder: string;
    /** The instance description. */
    infoFile: string;
    /** The lock directory, owned by `proper-lockfile`. */
    lockFile: string;
    /** The JSON Lines log. */
    logFile: string;
}
export interface DevInstanceIdentity {
    /** The command that owns the instance, for example `app dev`. */
    command: string;
    /** The project directory. */
    directory: string;
    /** Distinguishes several instances of the same command in the same directory. */
    key?: string;
}
export interface AcquireDevInstanceOptions extends DevInstanceIdentity {
    /** Initial command-specific details. */
    details?: {
        [key: string]: unknown;
    };
    /** Receives the exact JSONL bytes written to the session log. The caller owns this stream. */
    logOutput?: Writable;
}
export interface DevInstanceUpdate {
    status?: DevInstanceInfo['status'];
    /** Merged into the existing details. */
    details?: {
        [key: string]: unknown;
    };
}
export interface DevInstance {
    readonly info: DevInstanceInfo;
    readonly paths: DevInstancePaths;
    /** Aborts when this session loses its lock or output fails. Consumers must stop their owned work. */
    readonly abortSignal: AbortSignal;
    /** Updates and persists the instance description. */
    update: (changes: DevInstanceUpdate) => Promise<void>;
    /** Appends one entry to the JSON Lines log. */
    log: (entry: Omit<DevInstanceLogEntry, 'timestamp'> & {
        timestamp?: string;
    }) => Promise<void>;
    /**
     * Wraps a stream so every line written to it is also appended to the log, without ANSI styling.
     * The returned stream forwards the original bytes to `target` unchanged.
     */
    tee: (target: Writable, options: {
        source: string;
        /** Treat each string write as a complete message. Byte writes remain buffered into lines. */
        stringWritesAreMessages?: boolean;
    }) => Writable;
    /** Closes the log, removes the instance description and releases the lock. Safe to call more than once. */
    release: () => Promise<void>;
}
/**
 * Returns the files used by a dev instance. Every instance of a project shares `.shopify/dev`,
 * with one `<slug>.json`, `<slug>.lock` and `<slug>.jsonl` per command and key.
 *
 * @param identity - The command, directory and optional key of the instance.
 * @returns The paths of the instance files.
 */
export declare function devInstancePaths(identity: DevInstanceIdentity): DevInstancePaths;
/**
 * Marks this process as the running instance of a long-running command for a project.
 *
 * Mutual exclusion uses `proper-lockfile`: an atomic `mkdir` lock whose mtime this process refreshes as a heartbeat.
 * An exclusive startup directory serializes abandoned-lock recovery. An interrupted startup requires explicit
 * cleanup of that directory. A live owner blocks takeover, including when its heartbeat is stale.
 *
 * @param options - The instance identity, initial details.
 * @returns The acquired instance.
 * @throws AbortError with code {@link DEV_INSTANCE_ALREADY_RUNNING_CODE} when another live instance holds the lock.
 */
export declare function acquireDevInstance(options: AcquireDevInstanceOptions): Promise<DevInstance>;
/**
 * Acquires a dev instance and releases it after the callback completes or throws.
 *
 * @param options - The command, project and output options for the instance.
 * @param execute - The work to run while the instance is owned.
 * @returns The callback's result after instance cleanup completes.
 */
export declare function withDevInstance<T>(options: AcquireDevInstanceOptions, execute: (instance: DevInstance) => T | Promise<T>): Promise<T>;
/**
 * Returns the running instance of a command for a project, if there is one.
 *
 * @param identity - The command, directory and optional key of the instance.
 * @returns The instance description, or undefined when no live instance holds the lock.
 */
export declare function readDevInstance(identity: DevInstanceIdentity): Promise<DevInstanceInfo | undefined>;
/**
 * Lists every running dev instance of a project, across commands and keys.
 *
 * @param directory - The project directory.
 * @returns The descriptions of the live instances.
 */
export declare function listDevInstances(directory: string): Promise<DevInstanceInfo[]>;
/**
 * Checks whether a process exists. A process owned by another user counts as alive.
 *
 * @param pid - The process ID.
 * @returns True when the process exists.
 */
export declare function isProcessAlive(pid: number): boolean;

Existing type declarations

packages/cli-kit/dist/private/node/ui/components/ConcurrentOutput.d.ts
@@ -1,3 +1,4 @@
+import { ConcurrentOutputContext, getConcurrentOutputContext, useConcurrentOutputContext } from '../../output-context.js';
 import { type OutputProcess } from '../../../../public/node/output.js';
 import { AbortSignal } from '../../../../public/node/abort.js';
 import { FunctionComponent } from 'react';
@@ -13,12 +14,6 @@ export interface ConcurrentOutputProps {
     keepRunningAfterProcessesResolve?: boolean;
     useAlternativeColorPalette?: boolean;
 }
-interface ConcurrentOutputContext {
-    outputPrefix?: string;
-    /** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */
-    stripAnsi?: boolean;
-}
-declare function useConcurrentOutputContext<T>(context: ConcurrentOutputContext, callback: () => T): T;
 /** Runs finite processes concurrently and routes their output through the shared diagnostic context. */
 export declare function runConcurrentProcessesForJson({ processes, abortSignal, }: Pick<ConcurrentOutputProps, 'processes' | 'abortSignal'>): Promise<void>;
 /**
@@ -55,4 +50,4 @@ export declare function runConcurrentProcessesForJson({ processes, abortSignal,
  * ```
  */
 declare const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps>;
-export { ConcurrentOutput, ConcurrentOutputContext, useConcurrentOutputContext };
\ No newline at end of file
+export { ConcurrentOutput, ConcurrentOutputContext, getConcurrentOutputContext, useConcurrentOutputContext };
\ No newline at end of file

Base automatically changed from codex/dev-instance-support to main October 21, 2026 13:40

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/app @shopify/app package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant