@p4code/cli 0.2.4 → 0.2.6

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.
Files changed (36) hide show
  1. package/dist/bin.mjs +726 -72
  2. package/dist/client/assets/{DiffPanel-CkrZfPGI.js → DiffPanel-BooVz7M0.js} +3 -3
  3. package/dist/client/assets/FilePreviewPanel-CctHlOR7.js +2230 -0
  4. package/dist/client/assets/PreviewPanel-iNGC0YrW.js +2 -0
  5. package/dist/client/assets/{PullRequestCodeTab-DXvCq9PN.js → PullRequestCodeTab-ilyykLSQ.js} +4 -4
  6. package/dist/client/assets/arrow-right-CB1n5rpS.js +2 -0
  7. package/dist/client/assets/{fileCommentAnnotations-jIuAO40K.js → fileCommentAnnotations-Ckjc-0ZU.js} +2 -2
  8. package/dist/client/assets/{index-BZdqP0xD.js → index-Cvhs37GD.js} +1539 -241
  9. package/dist/client/assets/pierre-dark-CpLgRqie.js +2 -0
  10. package/dist/client/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +2 -0
  11. package/dist/client/assets/pierre-dark-soft-kZQmAZld.js +2 -0
  12. package/dist/client/assets/pierre-dark-tritanopia-CpjhbsIL.js +2 -0
  13. package/dist/client/assets/pierre-dark-vibrant-CpQYzh95.js +2 -0
  14. package/dist/client/assets/pierre-light-CoaEpmwp.js +2 -0
  15. package/dist/client/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +2 -0
  16. package/dist/client/assets/pierre-light-soft-lWLdNTOI.js +2 -0
  17. package/dist/client/assets/pierre-light-tritanopia-CEbqgOJL.js +2 -0
  18. package/dist/client/assets/pierre-light-vibrant-D80Fkn33.js +2 -0
  19. package/dist/client/assets/renderFileChildren-1GxlP08z.js +2 -0
  20. package/dist/client/assets/terminal-links-Dcw5kSW5.js +47 -0
  21. package/dist/client/assets/toggle-group-DngghwoH.js +2 -0
  22. package/dist/client/assets/{worker-aIZfs-9q.js → worker-CTbBhd8-.js} +24 -21
  23. package/dist/client/index.html +2 -2
  24. package/package.json +2 -2
  25. package/dist/client/assets/FilePreviewPanel-ExZ7jSHB.js +0 -1778
  26. package/dist/client/assets/PreviewPanel-DFC5gUqR.js +0 -2
  27. package/dist/client/assets/arrow-right-BsoyxJft.js +0 -2
  28. package/dist/client/assets/pierre-dark-sU4Zdns8.js +0 -2
  29. package/dist/client/assets/pierre-dark-soft-HXgun5vs.js +0 -2
  30. package/dist/client/assets/pierre-dark-vibrant-D4RhcSIK.js +0 -2
  31. package/dist/client/assets/pierre-light-DWBk51d8.js +0 -2
  32. package/dist/client/assets/pierre-light-soft-CodEzPxf.js +0 -2
  33. package/dist/client/assets/pierre-light-vibrant-B76X7i5Y.js +0 -2
  34. package/dist/client/assets/renderFileChildren-CbnyGaFt.js +0 -2
  35. package/dist/client/assets/terminal-links-CaZNZumK.js +0 -47
  36. package/dist/client/assets/toggle-group-Aw7xeuHS.js +0 -2
package/dist/bin.mjs CHANGED
@@ -237,7 +237,7 @@ const make$87 = () => {
237
237
  const layer$79 = Layer.sync(NetService, make$87);
238
238
  //#endregion
239
239
  //#region package.json
240
- var version = "0.2.4";
240
+ var version = "0.2.6";
241
241
  //#endregion
242
242
  //#region src/config.ts
243
243
  /**
@@ -964,6 +964,41 @@ const PortSchema = Schema$1.Int.check(Schema$1.isBetween({
964
964
  }));
965
965
  const IsoDateTime = Schema$1.String;
966
966
  /**
967
+ * Wire codec for server→client arrays whose element unions grow over time (new
968
+ * literal members, new struct variants). Decoding drops elements this build
969
+ * cannot decode instead of failing the whole payload — a client has to keep
970
+ * decoding configs sent by servers newer than itself, and rejecting the payload
971
+ * would take the connection down over data the client could not act on anyway.
972
+ * Encoding is the plain array encoding.
973
+ */
974
+ const ForwardCompatibleArray = (element) => {
975
+ const decodeElement = Schema$1.decodeUnknownOption(element);
976
+ return Schema$1.Array(Schema$1.Unknown).pipe(Schema$1.decodeTo(Schema$1.Array(element), SchemaTransformation.transform({
977
+ decode: (values) => values.filter((value) => Option.isSome(decodeElement(value))),
978
+ encode: (values) => values
979
+ })));
980
+ };
981
+ /**
982
+ * Wire codec for a scalar literal union that grows over time.
983
+ *
984
+ * The sibling of {@link ForwardCompatibleArray}, for the case where the union
985
+ * is a field inside a struct rather than an array element: dropping the value
986
+ * is not an option, so an unrecognised literal decodes to `fallback` instead of
987
+ * failing the struct - and with it every struct that contains it.
988
+ *
989
+ * The fallback has to be a member this build already renders as "nothing
990
+ * actionable here", never one that asserts something specific about a state
991
+ * this build cannot know.
992
+ */
993
+ const LiteralWithFallback = (members, fallback) => {
994
+ const literals = Schema$1.Literals(members);
995
+ const decodeLiteral = Schema$1.decodeUnknownOption(literals);
996
+ return Schema$1.String.pipe(Schema$1.decodeTo(literals, SchemaTransformation.transform({
997
+ decode: (value) => Option.isSome(decodeLiteral(value)) ? value : fallback,
998
+ encode: (value) => value
999
+ })));
1000
+ };
1001
+ /**
967
1002
  * Construct a branded identifier. Enforces non-empty trimmed strings
968
1003
  */
969
1004
  const makeEntityId = (brand) => {
@@ -8018,19 +8053,18 @@ const KeybindingsInvalidEntryIssue = Schema$1.Struct({
8018
8053
  message: TrimmedNonEmptyString,
8019
8054
  index: Schema$1.Number
8020
8055
  });
8021
- const ServerConfigIssue = Schema$1.Union([KeybindingsMalformedConfigIssue, KeybindingsInvalidEntryIssue]);
8022
- const ServerConfigIssues = Schema$1.Array(ServerConfigIssue);
8023
- const ServerProviderState = Schema$1.Literals([
8056
+ const ServerConfigIssues = ForwardCompatibleArray(Schema$1.Union([KeybindingsMalformedConfigIssue, KeybindingsInvalidEntryIssue]));
8057
+ const ServerProviderState = LiteralWithFallback([
8024
8058
  "ready",
8025
8059
  "warning",
8026
8060
  "error",
8027
8061
  "disabled"
8028
- ]);
8029
- const ServerProviderAuthStatus = Schema$1.Literals([
8062
+ ], "warning");
8063
+ const ServerProviderAuthStatus = LiteralWithFallback([
8030
8064
  "authenticated",
8031
8065
  "unauthenticated",
8032
8066
  "unknown"
8033
- ]);
8067
+ ], "unknown");
8034
8068
  const ServerProviderAuth = Schema$1.Struct({
8035
8069
  status: ServerProviderAuthStatus,
8036
8070
  type: Schema$1.optional(TrimmedNonEmptyString),
@@ -8080,11 +8114,11 @@ const ServerProviderSkill = Schema$1.Struct({
8080
8114
  */
8081
8115
  const ServerProviderAvailability = Schema$1.Literals(["available", "unavailable"]);
8082
8116
  const ServerProviderContinuation = Schema$1.Struct({ groupKey: TrimmedNonEmptyString });
8083
- const ServerProviderVersionAdvisoryStatus = Schema$1.Literals([
8117
+ const ServerProviderVersionAdvisoryStatus = LiteralWithFallback([
8084
8118
  "unknown",
8085
8119
  "current",
8086
8120
  "behind_latest"
8087
- ]);
8121
+ ], "unknown");
8088
8122
  const ServerProviderVersionAdvisory = Schema$1.Struct({
8089
8123
  status: ServerProviderVersionAdvisoryStatus,
8090
8124
  currentVersion: Schema$1.NullOr(TrimmedNonEmptyString),
@@ -8094,14 +8128,14 @@ const ServerProviderVersionAdvisory = Schema$1.Struct({
8094
8128
  checkedAt: Schema$1.NullOr(IsoDateTime),
8095
8129
  message: Schema$1.NullOr(TrimmedNonEmptyString)
8096
8130
  });
8097
- const ServerProviderUpdateStatus = Schema$1.Literals([
8131
+ const ServerProviderUpdateStatus = LiteralWithFallback([
8098
8132
  "idle",
8099
8133
  "queued",
8100
8134
  "running",
8101
8135
  "succeeded",
8102
8136
  "failed",
8103
8137
  "unchanged"
8104
- ]);
8138
+ ], "idle");
8105
8139
  const ServerProviderUpdateState = Schema$1.Struct({
8106
8140
  status: ServerProviderUpdateStatus,
8107
8141
  startedAt: Schema$1.NullOr(IsoDateTime),
@@ -8141,7 +8175,7 @@ const ServerProvider = Schema$1.Struct({
8141
8175
  versionAdvisory: Schema$1.optionalKey(ServerProviderVersionAdvisory),
8142
8176
  updateState: Schema$1.optionalKey(ServerProviderUpdateState)
8143
8177
  });
8144
- const ServerProviders = Schema$1.Array(ServerProvider);
8178
+ const ServerProviders = ForwardCompatibleArray(ServerProvider);
8145
8179
  /**
8146
8180
  * Treat the optional `availability` as "available" when absent. This is
8147
8181
  * the rule legacy producers (which omit the field) and new producers
@@ -8311,7 +8345,7 @@ const ServerConfig = Schema$1.Struct({
8311
8345
  keybindings: ResolvedKeybindingsConfig,
8312
8346
  issues: ServerConfigIssues,
8313
8347
  providers: ServerProviders,
8314
- availableEditors: Schema$1.Array(EditorId),
8348
+ availableEditors: ForwardCompatibleArray(EditorId),
8315
8349
  observability: ServerObservability,
8316
8350
  settings: ServerSettings,
8317
8351
  /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
@@ -8891,10 +8925,13 @@ var ProjectListEntriesError = class extends Schema$1.TaggedErrorClass()("Project
8891
8925
  });
8892
8926
  }
8893
8927
  };
8894
- const ProjectReadFileInput = Schema$1.Struct({
8928
+ const ProjectReadFileInput = Schema$1.Union([Schema$1.Struct({
8895
8929
  cwd: TrimmedNonEmptyString,
8896
8930
  relativePath: TrimmedNonEmptyString.check(Schema$1.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH))
8897
- });
8931
+ }), Schema$1.Struct({
8932
+ cwd: TrimmedNonEmptyString,
8933
+ absolutePath: TrimmedNonEmptyString.check(Schema$1.isMaxLength(PROJECT_READ_FILE_PATH_MAX_LENGTH))
8934
+ })]);
8898
8935
  const ProjectReadFileResult = Schema$1.Struct({
8899
8936
  relativePath: TrimmedNonEmptyString,
8900
8937
  contents: Schema$1.String,
@@ -8910,6 +8947,7 @@ const ProjectReadFileResult = Schema$1.Struct({
8910
8947
  const ProjectFileFailure = Schema$1.Literals([
8911
8948
  "workspace_path_outside_root",
8912
8949
  "resolved_path_outside_root",
8950
+ "path_not_found",
8913
8951
  "path_not_file",
8914
8952
  "operation_failed"
8915
8953
  ]);
@@ -8926,6 +8964,7 @@ const ProjectFileOperation = Schema$1.Literals([
8926
8964
  var ProjectReadFileError = class extends Schema$1.TaggedErrorClass()("ProjectReadFileError", {
8927
8965
  cwd: Schema$1.optional(TrimmedNonEmptyString),
8928
8966
  relativePath: Schema$1.optional(TrimmedNonEmptyString),
8967
+ absolutePath: Schema$1.optional(TrimmedNonEmptyString),
8929
8968
  failure: Schema$1.optional(ProjectFileFailure),
8930
8969
  resolvedPath: Schema$1.optional(TrimmedNonEmptyString),
8931
8970
  resolvedWorkspaceRoot: Schema$1.optional(TrimmedNonEmptyString),
@@ -8935,9 +8974,11 @@ var ProjectReadFileError = class extends Schema$1.TaggedErrorClass()("ProjectRea
8935
8974
  cause: Schema$1.optional(Schema$1.Defect())
8936
8975
  }) {
8937
8976
  constructor(props) {
8977
+ const requestedPath = props.absolutePath ?? props.relativePath ?? "unknown";
8978
+ const message = props.failure === "workspace_path_outside_root" || props.failure === "resolved_path_outside_root" ? `File '${requestedPath}' is outside workspace root '${props.cwd}'.` : props.failure === "path_not_found" ? `File '${requestedPath}' was not found.` : `Failed to read workspace file '${requestedPath}' in '${props.cwd}'.`;
8938
8979
  super({
8939
8980
  ...props,
8940
- message: decodedProjectErrorMessage(props) ?? `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`
8981
+ message: decodedProjectErrorMessage(props) ?? message
8941
8982
  });
8942
8983
  }
8943
8984
  };
@@ -13030,6 +13071,136 @@ const make$80 = Effect.gen(function* () {
13030
13071
  });
13031
13072
  const layer$72 = Layer.effect(PairingGrantStore, make$80).pipe(Layer.provideMerge(layer$73));
13032
13073
  //#endregion
13074
+ //#region src/persistence/DatabaseSnapshot.ts
13075
+ /**
13076
+ * DatabaseSnapshot - pre-migration snapshot of the SQLite database.
13077
+ *
13078
+ * A failed migration used to be unrecoverable: the layer that runs migrations
13079
+ * is built during startup, so a migration that throws exits the process, the
13080
+ * supervisor (launchd KeepAlive, systemd Restart=always) starts the same broken
13081
+ * build again, and `effect_sql_migrations` has already advanced past what the
13082
+ * previous release understands - so reinstalling the old CLI does not recover
13083
+ * it either.
13084
+ *
13085
+ * This module gives the migration runner something to fall back to. Before a
13086
+ * boot that has migrations to apply, the database is snapshotted with SQLite's
13087
+ * own `VACUUM INTO`, which writes one consistent file without needing the WAL
13088
+ * and shared-memory sidecars. If the migration then fails, a marker is written
13089
+ * and the process exits; the *next* boot restores the snapshot before the
13090
+ * database is opened.
13091
+ *
13092
+ * Restoring on the next boot rather than in the failing process is what makes
13093
+ * this survive a power cut: the marker is written before any live file is
13094
+ * touched, and the restore is idempotent, so an interrupted restore simply
13095
+ * resumes.
13096
+ *
13097
+ * @module DatabaseSnapshot
13098
+ */
13099
+ /** SQLite spreads a live database across the main file plus these sidecars. */
13100
+ const DB_SIDECAR_SUFFIXES = ["-wal", "-shm"];
13101
+ const BACKUP_DIR_NAME = "db-backup";
13102
+ const SNAPSHOT_FILE_NAME = "state.sqlite";
13103
+ const STAGING_FILE_NAME = "state.sqlite.staging";
13104
+ const RESTORE_MARKER_FILE_NAME = ".restore-pending";
13105
+ /**
13106
+ * One snapshot slot, beside the database it protects.
13107
+ *
13108
+ * Beside rather than under `<baseDir>/runtime` so a snapshot always lands on
13109
+ * the same filesystem as the database - `rename` is only atomic within one -
13110
+ * and so a dev run under `<baseDir>/dev` can never restore over live state.
13111
+ */
13112
+ const databaseSnapshotPaths = Effect.fn("databaseSnapshotPaths")(function* (dbPath) {
13113
+ const path = yield* Path.Path;
13114
+ const backupDir = path.join(path.dirname(dbPath), BACKUP_DIR_NAME);
13115
+ return {
13116
+ backupDir,
13117
+ snapshotPath: path.join(backupDir, SNAPSHOT_FILE_NAME),
13118
+ stagingPath: path.join(backupDir, STAGING_FILE_NAME),
13119
+ markerPath: path.join(backupDir, RESTORE_MARKER_FILE_NAME)
13120
+ };
13121
+ });
13122
+ /**
13123
+ * fsync a path so the rename or copy above it survives a power cut. Directory
13124
+ * handles cannot be opened for writing, so both files and directories are
13125
+ * opened read-only and synced through the descriptor.
13126
+ */
13127
+ const syncPath = (target) => Effect.scoped(Effect.gen(function* () {
13128
+ yield* (yield* (yield* FileSystem.FileSystem).open(target, { flag: "r" })).sync;
13129
+ }));
13130
+ /**
13131
+ * Snapshot the database unless one is already there.
13132
+ *
13133
+ * Never overwrites: after a failed migration the live database may already
13134
+ * carry half of that migration, and the existing snapshot is the only copy of
13135
+ * the schema the previous release can still read.
13136
+ */
13137
+ const captureDatabaseSnapshot = Effect.fn("captureDatabaseSnapshot")(function* (dbPath) {
13138
+ const fs = yield* FileSystem.FileSystem;
13139
+ const sql = yield* SqlClient.SqlClient;
13140
+ const paths = yield* databaseSnapshotPaths(dbPath);
13141
+ if (yield* fs.exists(paths.snapshotPath)) return;
13142
+ yield* fs.makeDirectory(paths.backupDir, { recursive: true });
13143
+ yield* fs.remove(paths.stagingPath, { force: true });
13144
+ yield* sql`VACUUM INTO ${paths.stagingPath}`;
13145
+ yield* syncPath(paths.stagingPath);
13146
+ yield* fs.rename(paths.stagingPath, paths.snapshotPath);
13147
+ yield* syncPath(paths.backupDir);
13148
+ });
13149
+ /**
13150
+ * Record that the live database must be replaced by the snapshot.
13151
+ *
13152
+ * Written before anything touches a live file, so a crash between here and the
13153
+ * restore leaves the next boot able to tell that the database is not trusted.
13154
+ */
13155
+ const markDatabaseRestorePending = Effect.fn("markDatabaseRestorePending")(function* (dbPath) {
13156
+ const fs = yield* FileSystem.FileSystem;
13157
+ const paths = yield* databaseSnapshotPaths(dbPath);
13158
+ if (yield* fs.exists(paths.markerPath)) return;
13159
+ yield* fs.makeDirectory(paths.backupDir, { recursive: true });
13160
+ yield* Effect.scoped(Effect.gen(function* () {
13161
+ yield* (yield* fs.open(paths.markerPath, { flag: "wx" })).sync;
13162
+ }));
13163
+ yield* syncPath(paths.backupDir);
13164
+ });
13165
+ const databaseRestorePending = Effect.fn("databaseRestorePending")(function* (dbPath) {
13166
+ const fs = yield* FileSystem.FileSystem;
13167
+ const paths = yield* databaseSnapshotPaths(dbPath);
13168
+ return yield* fs.exists(paths.markerPath);
13169
+ });
13170
+ /**
13171
+ * Put the snapshot back, if one is pending. Call before the database is opened.
13172
+ *
13173
+ * Idempotent by construction: the marker is cleared only after the snapshot is
13174
+ * fully copied and synced, so an interrupted restore repeats harmlessly on the
13175
+ * next boot rather than leaving a half-copied database in place.
13176
+ */
13177
+ const restoreDatabaseSnapshotIfPending = Effect.fn("restoreDatabaseSnapshotIfPending")(function* (dbPath) {
13178
+ const fs = yield* FileSystem.FileSystem;
13179
+ const path = yield* Path.Path;
13180
+ const paths = yield* databaseSnapshotPaths(dbPath);
13181
+ if (!(yield* fs.exists(paths.markerPath))) return false;
13182
+ if (!(yield* fs.exists(paths.snapshotPath))) {
13183
+ yield* fs.remove(paths.markerPath, { force: true });
13184
+ return false;
13185
+ }
13186
+ yield* fs.copyFile(paths.snapshotPath, dbPath);
13187
+ yield* syncPath(dbPath);
13188
+ for (const suffix of DB_SIDECAR_SUFFIXES) yield* fs.remove(`${dbPath}${suffix}`, { force: true });
13189
+ yield* syncPath(path.dirname(dbPath));
13190
+ yield* fs.remove(paths.markerPath, { force: true });
13191
+ yield* fs.remove(paths.snapshotPath, { force: true });
13192
+ yield* syncPath(paths.backupDir);
13193
+ return true;
13194
+ });
13195
+ /** Drop a snapshot the migrations no longer need. */
13196
+ const discardDatabaseSnapshot = Effect.fn("discardDatabaseSnapshot")(function* (dbPath) {
13197
+ const fs = yield* FileSystem.FileSystem;
13198
+ const paths = yield* databaseSnapshotPaths(dbPath);
13199
+ yield* fs.remove(paths.markerPath, { force: true });
13200
+ yield* fs.remove(paths.snapshotPath, { force: true });
13201
+ yield* fs.remove(paths.stagingPath, { force: true });
13202
+ });
13203
+ //#endregion
13033
13204
  //#region src/persistence/Migrations/001_OrchestrationEvents.ts
13034
13205
  var _001_OrchestrationEvents_default = Effect.gen(function* () {
13035
13206
  const sql = yield* SqlClient.SqlClient;
@@ -14922,6 +15093,59 @@ const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusi
14922
15093
  yield* migrations.length === 0 ? Effect.logDebug("Database schema is current") : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
14923
15094
  return executedMigrations;
14924
15095
  });
15096
+ /**
15097
+ * Highest migration already recorded in the database, or 0 for a database that
15098
+ * has never been migrated.
15099
+ *
15100
+ * Read from `sqlite_master` first because the tracking table does not exist on
15101
+ * a first boot, and a missing-table error here would be indistinguishable from
15102
+ * a real failure.
15103
+ */
15104
+ const latestAppliedMigrationId = Effect.fn("latestAppliedMigrationId")(function* () {
15105
+ const sql = yield* SqlClient.SqlClient;
15106
+ if ((yield* sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'`).length === 0) return 0;
15107
+ return (yield* sql`SELECT max(migration_id) AS id FROM effect_sql_migrations`)[0]?.id ?? 0;
15108
+ });
15109
+ /**
15110
+ * Run migrations with a snapshot to fall back to.
15111
+ *
15112
+ * Only a boot that actually has migrations to apply pays for the snapshot, so a
15113
+ * normal restart still opens the database and serves immediately.
15114
+ *
15115
+ * On failure the snapshot is deliberately *not* restored here: the database is
15116
+ * still open on this connection, and a restore under an open connection is how
15117
+ * a half-copied file gets read. The marker written instead makes the next boot
15118
+ * restore it before anything opens the database, which is also what makes an
15119
+ * interrupted restore resume rather than corrupt.
15120
+ */
15121
+ const runGuardedMigrations = Effect.fn("runGuardedMigrations")(function* (input) {
15122
+ const appliedId = yield* latestAppliedMigrationId();
15123
+ const throughId = input.options?.toMigrationInclusive;
15124
+ const targetId = migrationEntries.filter(([id]) => throughId === void 0 || id <= throughId).reduce((highest, [id]) => id > highest ? id : highest, 0);
15125
+ if (targetId <= appliedId) {
15126
+ yield* discardSnapshotQuietly(input.dbPath);
15127
+ return yield* runMigrations(input.options);
15128
+ }
15129
+ yield* Effect.log("Snapshotting the database before migrations").pipe(Effect.annotateLogs({
15130
+ appliedMigrationId: appliedId,
15131
+ targetMigrationId: targetId
15132
+ }));
15133
+ yield* captureDatabaseSnapshot(input.dbPath).pipe(Effect.tapCause((cause) => Effect.logError("Refusing to migrate without a database snapshot").pipe(Effect.annotateLogs({
15134
+ dbPath: input.dbPath,
15135
+ cause
15136
+ }))));
15137
+ const executed = yield* runMigrations(input.options).pipe(Effect.tapCause(() => markDatabaseRestorePending(input.dbPath).pipe(Effect.andThen(Effect.logError("Migration failed; the database will be restored on the next start").pipe(Effect.annotateLogs({
15138
+ appliedMigrationId: appliedId,
15139
+ targetMigrationId: targetId
15140
+ }))), Effect.tapError((cause) => Effect.logError("Could not mark the database for restore").pipe(Effect.annotateLogs({ cause }))), Effect.ignore)));
15141
+ yield* discardSnapshotQuietly(input.dbPath);
15142
+ return executed;
15143
+ });
15144
+ /**
15145
+ * A snapshot that cannot be deleted is wasted disk, not a reason to refuse to
15146
+ * start - the schema it protected is already applied by the time this runs.
15147
+ */
15148
+ const discardSnapshotQuietly = (dbPath) => discardDatabaseSnapshot(dbPath).pipe(Effect.tapError((cause) => Effect.logWarning("Could not discard the database snapshot").pipe(Effect.annotateLogs({ cause }))), Effect.ignore);
14925
15149
  Layer.effectDiscard(runMigrations());
14926
15150
  //#endregion
14927
15151
  //#region src/persistence/Layers/Sqlite.ts
@@ -14934,17 +15158,27 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (co
14934
15158
  const loader = defaultSqliteClientLoaders[runtime];
14935
15159
  return (yield* Effect.promise(loader)).layer(config);
14936
15160
  }, Layer.unwrap);
14937
- const setup$1 = Layer.effectDiscard(Effect.gen(function* () {
15161
+ const applyPragmas = Effect.gen(function* () {
14938
15162
  const sql = yield* SqlClient.SqlClient;
14939
15163
  yield* sql`PRAGMA journal_mode = WAL;`;
14940
15164
  yield* sql`PRAGMA foreign_keys = ON;`;
14941
- yield* runMigrations();
14942
- }));
15165
+ });
15166
+ const setupFile = (dbPath) => Layer.effectDiscard(applyPragmas.pipe(Effect.andThen(runGuardedMigrations({ dbPath }))));
15167
+ /**
15168
+ * The in-memory database used by tests has no file to snapshot and nothing that
15169
+ * survives the process to restore.
15170
+ *
15171
+ * A separate layer rather than a nullable path, so the memory setup keeps
15172
+ * needing nothing but `SqlClient`: sharing one function would put the file
15173
+ * path's `FileSystem | Path` requirement into every test layer built on it.
15174
+ */
15175
+ const setupMemory = Layer.effectDiscard(applyPragmas.pipe(Effect.andThen(runMigrations())));
14943
15176
  const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath) {
14944
15177
  const fs = yield* FileSystem.FileSystem;
14945
15178
  const path = yield* Path.Path;
14946
15179
  yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
14947
- return Layer.provideMerge(setup$1, makeRuntimeSqliteLayer({
15180
+ yield* restoreDatabaseSnapshotIfPending(dbPath).pipe(Effect.tap((restored) => restored ? Effect.logWarning("Restored the database snapshot left by a failed migration") : Effect.void));
15181
+ return Layer.provideMerge(setupFile(dbPath), makeRuntimeSqliteLayer({
14948
15182
  filename: dbPath,
14949
15183
  spanAttributes: {
14950
15184
  "db.name": path.basename(dbPath),
@@ -14952,7 +15186,7 @@ const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(functio
14952
15186
  }
14953
15187
  }));
14954
15188
  }, Layer.unwrap);
14955
- Layer.provideMerge(setup$1, makeRuntimeSqliteLayer({ filename: ":memory:" }));
15189
+ Layer.provideMerge(setupMemory, makeRuntimeSqliteLayer({ filename: ":memory:" }));
14956
15190
  const layerConfig = Layer.unwrap(Effect.map(Effect.service(ServerConfig$1), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)));
14957
15191
  const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
14958
15192
  const serverAuthInternalErrorContext = { cause: Schema$1.Defect() };
@@ -29913,11 +30147,14 @@ const make$64 = Effect.fn("cloud.boot_service.make")(function* (input) {
29913
30147
  baseDir: input.baseDir
29914
30148
  };
29915
30149
  }).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })), Effect.withSpan("cloud.boot_service.status"));
30150
+ const readUnit = fs.exists(unitPath).pipe(Effect.flatMap((exists) => exists ? fs.readFileString(unitPath).pipe(Effect.map(Option.some)) : Effect.succeed(Option.none())), Effect.mapError((cause) => new BootServiceInstallError({ cause })), Effect.withSpan("cloud.boot_service.read_unit"));
29916
30151
  return BootService.of({
29917
30152
  install,
29918
30153
  uninstall,
29919
30154
  restart,
29920
30155
  status,
30156
+ readUnit,
30157
+ revertUnit: rollbackFailedInstall,
29921
30158
  logPath
29922
30159
  });
29923
30160
  });
@@ -31570,7 +31807,15 @@ const PersistedServerRuntimeState = Schema$1.Struct({
31570
31807
  host: Schema$1.optional(Schema$1.String),
31571
31808
  port: Schema$1.Int,
31572
31809
  origin: Schema$1.String,
31573
- startedAt: Schema$1.String
31810
+ startedAt: Schema$1.String,
31811
+ /**
31812
+ * Which build wrote this. `p4c service update` restarts the service and then
31813
+ * has to tell whether what came back is the new build or the old one still
31814
+ * running - the pid alone cannot say that.
31815
+ *
31816
+ * Optional so a file written by an older server still decodes.
31817
+ */
31818
+ cliVersion: Schema$1.optional(Schema$1.String)
31574
31819
  });
31575
31820
  var ServerRuntimeStateError = class extends Schema$1.TaggedErrorClass()("ServerRuntimeStateError", {
31576
31821
  operation: Schema$1.Literals([
@@ -31596,7 +31841,8 @@ const makePersistedServerRuntimeState = (input) => Effect.map(DateTime.now, (now
31596
31841
  ...input.config.host ? { host: input.config.host } : {},
31597
31842
  port: input.port,
31598
31843
  origin: runtimeOriginForConfig(input.config, input.port),
31599
- startedAt: DateTime.formatIso(now)
31844
+ startedAt: DateTime.formatIso(now),
31845
+ cliVersion: version
31600
31846
  }));
31601
31847
  const persistServerRuntimeState = (input) => writeFileStringAtomically({
31602
31848
  filePath: input.path,
@@ -33014,7 +33260,8 @@ const ICON_SOURCE_FILES = [
33014
33260
  "src/index.html"
33015
33261
  ];
33016
33262
  const LINK_ICON_HTML_RE = /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
33017
- const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
33263
+ const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
33264
+ const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
33018
33265
  var ProjectFaviconResolutionError = class extends Schema$1.TaggedErrorClass()("ProjectFaviconResolutionError", {
33019
33266
  operation: Schema$1.Literals([
33020
33267
  "normalize-workspace",
@@ -33036,8 +33283,11 @@ var ProjectFaviconResolver = class extends Context.Service()("@p4code/cli/projec
33036
33283
  function extractIconHref(source) {
33037
33284
  const htmlMatch = source.match(LINK_ICON_HTML_RE);
33038
33285
  if (htmlMatch?.[1]) return htmlMatch[1];
33039
- const objMatch = source.match(LINK_ICON_OBJ_RE);
33040
- if (objMatch?.[1]) return objMatch[1];
33286
+ for (const run of source.split("}")) {
33287
+ if (!ICON_REL_RE.test(run)) continue;
33288
+ const hrefMatch = run.match(ICON_HREF_RE);
33289
+ if (hrefMatch?.[1]) return hrefMatch[1];
33290
+ }
33041
33291
  return null;
33042
33292
  }
33043
33293
  const optionOnNotFound$1 = (effect) => effect.pipe(Effect.map(Option.some), Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error) }));
@@ -33942,7 +34192,16 @@ Object.freeze({
33942
34192
  fromEnd: 0
33943
34193
  });
33944
34194
  //#endregion
33945
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/cleanLastNewline.js
34195
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/getHunkSideBoundaries.js
34196
+ /** Converts a unified hunk side's start/count into its consumed-file range. */
34197
+ function getHunkSideStartBoundary(start, count) {
34198
+ return start - (count === 0 ? 0 : 1);
34199
+ }
34200
+ function getHunkSideEndBoundary(start, count) {
34201
+ return getHunkSideStartBoundary(start, count) + count;
34202
+ }
34203
+ //#endregion
34204
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/cleanLastNewline.js
33946
34205
  function cleanLastNewline(contents) {
33947
34206
  let end = contents.length;
33948
34207
  if (contents.charCodeAt(end - 1) === 10) {
@@ -33952,7 +34211,7 @@ function cleanLastNewline(contents) {
33952
34211
  return contents.slice(0, end);
33953
34212
  }
33954
34213
  //#endregion
33955
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/detachString.js
34214
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/detachString.js
33956
34215
  const stringDetachEncoder = new TextEncoder();
33957
34216
  const stringDetachDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
33958
34217
  const SURROGATE_CODE_UNIT_PATTERN = /[\uD800-\uDFFF]/;
@@ -33970,7 +34229,152 @@ function detachString(value) {
33970
34229
  return stringDetachDecoder.decode(stringDetachBuffer.subarray(0, written));
33971
34230
  }
33972
34231
  //#endregion
33973
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/parsePatchFiles.js
34232
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/realignChangeContent.js
34233
+ const MAX_ALIGNMENT_COMPARISONS = 4096;
34234
+ const MIN_IMPROVEMENT_PER_PAIR = .5;
34235
+ /**
34236
+ * Re-split count-mismatched change blocks in every hunk so paired lines are
34237
+ * chosen by content similarity instead of position, then slide blank-line
34238
+ * insert/delete blocks to the top of their blank run. Mutates `hunks` in
34239
+ * place; rendered row counts are unchanged (a split block covers the same
34240
+ * split/unified rows as the original).
34241
+ */
34242
+ function realignChangeContentBySimilarity(diff) {
34243
+ for (const hunk of diff.hunks) {
34244
+ for (let index = 0; index < hunk.hunkContent.length; index++) {
34245
+ const content = hunk.hunkContent[index];
34246
+ if (content.type !== "change") continue;
34247
+ const replacement = realignChangeBlock(diff, content);
34248
+ if (replacement != null) {
34249
+ hunk.hunkContent.splice(index, 1, ...replacement);
34250
+ index += replacement.length - 1;
34251
+ }
34252
+ }
34253
+ slideBlankBoundaryBlocksUp(hunk, diff);
34254
+ }
34255
+ }
34256
+ /**
34257
+ * Slide pure insert/delete blocks made entirely of blank lines to the top of
34258
+ * the blank run they sit in. Adding or removing a blank line next to
34259
+ * existing blanks is ambiguous, and the diff library reports the change at
34260
+ * the run's bottom — so pressing Enter at the end of a line marks a blank
34261
+ * *below* the caret as inserted while the caret's own new line renders as
34262
+ * context. Sliding up anchors the change to the content above it (the caret
34263
+ * line after an Enter) instead.
34264
+ *
34265
+ * The slide is all-or-nothing: it only applies when the block comes to rest
34266
+ * directly beneath remaining in-hunk content. A slide that would consume the
34267
+ * hunk's entire leading context was stopped by the hunk's edge — a context
34268
+ * window cut, not the top of the blank run — and that landing spot is
34269
+ * arbitrary, so the block keeps the library's bottom-of-run anchor (which
34270
+ * sits against the content below the run). Non-blank blocks never slide, so
34271
+ * code that merely ends like its neighbor (an added function before an
34272
+ * identical `}`) keeps the library's canonical position.
34273
+ */
34274
+ function slideBlankBoundaryBlocksUp(hunk, diff) {
34275
+ const { hunkContent } = hunk;
34276
+ for (let index = 1; index < hunkContent.length; index++) {
34277
+ const block = hunkContent[index];
34278
+ const previous = hunkContent[index - 1];
34279
+ if (block.type !== "change" || block.additions > 0 && block.deletions > 0 || previous.type !== "context") continue;
34280
+ const isInsert = block.additions > 0;
34281
+ const lines = isInsert ? diff.additionLines : diff.deletionLines;
34282
+ const blockStart = isInsert ? block.additionLineIndex : block.deletionLineIndex;
34283
+ const blockLength = isInsert ? block.additions : block.deletions;
34284
+ const unit = lines[blockStart] ?? "";
34285
+ if (unit.trim() !== "") continue;
34286
+ let uniform = true;
34287
+ for (let offset = 1; offset < blockLength; offset++) if (lines[blockStart + offset] !== unit) {
34288
+ uniform = false;
34289
+ break;
34290
+ }
34291
+ if (!uniform) continue;
34292
+ let slide = 0;
34293
+ while (slide < previous.lines && diff.additionLines[previous.additionLineIndex + previous.lines - 1 - slide] === unit) slide++;
34294
+ if (slide === 0) continue;
34295
+ if (index === 1 && slide === previous.lines) continue;
34296
+ block.additionLineIndex -= slide;
34297
+ block.deletionLineIndex -= slide;
34298
+ const blockAdditionEnd = block.additionLineIndex + block.additions;
34299
+ const blockDeletionEnd = block.deletionLineIndex + block.deletions;
34300
+ const next = hunkContent[index + 1];
34301
+ if (next?.type === "context") {
34302
+ next.lines += slide;
34303
+ next.additionLineIndex = blockAdditionEnd;
34304
+ next.deletionLineIndex = blockDeletionEnd;
34305
+ } else hunkContent.splice(index + 1, 0, {
34306
+ type: "context",
34307
+ lines: slide,
34308
+ additionLineIndex: blockAdditionEnd,
34309
+ deletionLineIndex: blockDeletionEnd
34310
+ });
34311
+ previous.lines -= slide;
34312
+ if (previous.lines === 0) {
34313
+ hunkContent.splice(index - 1, 1);
34314
+ index--;
34315
+ }
34316
+ }
34317
+ }
34318
+ function realignChangeBlock(diff, content) {
34319
+ const { deletions, additions, deletionLineIndex, additionLineIndex } = content;
34320
+ const pairCount = Math.min(deletions, additions);
34321
+ const surplus = Math.abs(additions - deletions);
34322
+ if (pairCount === 0 || surplus === 0 || pairCount * (surplus + 1) > MAX_ALIGNMENT_COMPARISONS) return null;
34323
+ const strippedDeletions = [];
34324
+ for (let line = 0; line < deletions; line++) strippedDeletions.push(stripWhitespace(diff.deletionLines[deletionLineIndex + line] ?? ""));
34325
+ const strippedAdditions = [];
34326
+ for (let line = 0; line < additions; line++) strippedAdditions.push(stripWhitespace(diff.additionLines[additionLineIndex + line] ?? ""));
34327
+ const additionsAreLonger = additions > deletions;
34328
+ let bestOffset = 0;
34329
+ let bestScore = -1;
34330
+ for (let offset = 0; offset <= surplus; offset++) {
34331
+ let score = 0;
34332
+ for (let pair = 0; pair < pairCount; pair++) score += lineSimilarity(strippedDeletions[pair + (additionsAreLonger ? 0 : offset)], strippedAdditions[pair + (additionsAreLonger ? offset : 0)]);
34333
+ if (offset === 0) bestScore = score + pairCount * MIN_IMPROVEMENT_PER_PAIR;
34334
+ else if (score > bestScore) {
34335
+ bestScore = score;
34336
+ bestOffset = offset;
34337
+ }
34338
+ }
34339
+ if (bestOffset === 0) return null;
34340
+ const blocks = [];
34341
+ const pushBlock = (blockDeletions, blockAdditions, blockDeletionIndex, blockAdditionIndex) => {
34342
+ if (blockDeletions > 0 || blockAdditions > 0) blocks.push({
34343
+ type: "change",
34344
+ deletions: blockDeletions,
34345
+ additions: blockAdditions,
34346
+ deletionLineIndex: blockDeletionIndex,
34347
+ additionLineIndex: blockAdditionIndex
34348
+ });
34349
+ };
34350
+ if (additionsAreLonger) {
34351
+ pushBlock(0, bestOffset, deletionLineIndex, additionLineIndex);
34352
+ pushBlock(pairCount, pairCount, deletionLineIndex, additionLineIndex + bestOffset);
34353
+ pushBlock(0, additions - pairCount - bestOffset, deletionLineIndex + pairCount, additionLineIndex + bestOffset + pairCount);
34354
+ } else {
34355
+ pushBlock(bestOffset, 0, deletionLineIndex, additionLineIndex);
34356
+ pushBlock(pairCount, pairCount, deletionLineIndex + bestOffset, additionLineIndex);
34357
+ pushBlock(deletions - pairCount - bestOffset, 0, deletionLineIndex + bestOffset + pairCount, additionLineIndex + pairCount);
34358
+ }
34359
+ return blocks;
34360
+ }
34361
+ const WHITESPACE = /\s+/g;
34362
+ function stripWhitespace(line) {
34363
+ return line.replace(WHITESPACE, "");
34364
+ }
34365
+ function lineSimilarity(a, b) {
34366
+ if (a === b) return 1;
34367
+ const maxLength = Math.max(a.length, b.length);
34368
+ const minLength = Math.min(a.length, b.length);
34369
+ if (minLength === 0) return 0;
34370
+ let prefix = 0;
34371
+ while (prefix < minLength && a[prefix] === b[prefix]) prefix++;
34372
+ let suffix = 0;
34373
+ while (suffix < minLength - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
34374
+ return (prefix + suffix) / maxLength;
34375
+ }
34376
+ //#endregion
34377
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/parsePatchFiles.js
33974
34378
  function processPatch(data, cacheKeyPrefix, throwOnError) {
33975
34379
  try {
33976
34380
  return _processPatch(data, cacheKeyPrefix, throwOnError);
@@ -34046,9 +34450,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34046
34450
  if (currentFile.deletionLines.length === 1 && oldFile?.contents === "") currentFile.deletionLines.length = 0;
34047
34451
  for (const line of lines) {
34048
34452
  if (line.startsWith("diff --git")) {
34049
- const filenameMatch$1 = line.trim().match(ALTERNATE_FILE_NAMES_GIT);
34050
- const prevName = filenameMatch$1?.[1] ?? filenameMatch$1?.[2];
34051
- const name = filenameMatch$1?.[3] ?? filenameMatch$1?.[4];
34453
+ const filenameMatch = line.trim().match(ALTERNATE_FILE_NAMES_GIT);
34454
+ const prevName = filenameMatch?.[1] ?? filenameMatch?.[2];
34455
+ const name = filenameMatch?.[3] ?? filenameMatch?.[4];
34052
34456
  if (prevName == null || name == null) {
34053
34457
  if (throwOnError) throw Error("parsePatchContent: invalid git diff header");
34054
34458
  else console.error("parsePatchContent: invalid git diff header", line);
@@ -34195,9 +34599,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34195
34599
  if (throwOnError && (parsedAdditionLines !== hunkData.additionCount || parsedDeletionLines !== hunkData.deletionCount)) throw Error("parsePatchContent: hunk line count mismatch");
34196
34600
  hunkData.additionLines = additionLines;
34197
34601
  hunkData.deletionLines = deletionLines;
34198
- hunkData.collapsedBefore = Math.max(hunkData.additionStart - 1 - lastHunkEnd, 0);
34602
+ hunkData.collapsedBefore = Math.max(getHunkSideStartBoundary(hunkData.additionStart, hunkData.additionCount) - lastHunkEnd, 0);
34199
34603
  currentFile.hunks.push(hunkData);
34200
- lastHunkEnd = hunkData.additionStart + hunkData.additionCount - 1;
34604
+ lastHunkEnd = getHunkSideEndBoundary(hunkData.additionStart, hunkData.additionCount);
34201
34605
  for (const content of hunkData.hunkContent) if (content.type === "context") {
34202
34606
  hunkData.splitLineCount += content.lines;
34203
34607
  hunkData.unifiedLineCount += content.lines;
@@ -34214,9 +34618,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34214
34618
  if (throwOnError && isPartial && !isGitDiff && currentFile.hunks.length === 0) throw Error("parsePatchContent: unified file has no hunks");
34215
34619
  if (currentFile.hunks.length > 0 && !isPartial && currentFile.additionLines.length > 0 && currentFile.deletionLines.length > 0) {
34216
34620
  const lastHunk = currentFile.hunks[currentFile.hunks.length - 1];
34217
- const lastHunkEnd$1 = lastHunk.additionStart + lastHunk.additionCount - 1;
34621
+ const lastHunkEnd = getHunkSideEndBoundary(lastHunk.additionStart, lastHunk.additionCount);
34218
34622
  const totalFileLines = currentFile.additionLines.length;
34219
- const collapsedAfter = Math.max(totalFileLines - lastHunkEnd$1, 0);
34623
+ const collapsedAfter = Math.max(totalFileLines - lastHunkEnd, 0);
34220
34624
  currentFile.splitLineCount += collapsedAfter;
34221
34625
  currentFile.unifiedLineCount += collapsedAfter;
34222
34626
  }
@@ -34227,6 +34631,7 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34227
34631
  else if (oldFile != null && oldFile.contents !== "" && (newFile == null || newFile.contents === "")) currentFile.type = "deleted";
34228
34632
  }
34229
34633
  if (currentFile.type !== "rename-pure" && currentFile.type !== "rename-changed") currentFile.prevName = void 0;
34634
+ realignChangeContentBySimilarity(currentFile);
34230
34635
  return currentFile;
34231
34636
  }
34232
34637
  /**
@@ -38884,7 +39289,7 @@ const LINEAR_ISSUE_FIELDS = [
38884
39289
  * a Linear workspace is edited by people at human speed rather than by agents
38885
39290
  * at machine speed.
38886
39291
  */
38887
- const POLL_INTERVAL$1 = Duration.seconds(30);
39292
+ const POLL_INTERVAL$2 = Duration.seconds(30);
38888
39293
  /** Linear's cap on one page. Asking for more is an error, not a bigger page. */
38889
39294
  const LINEAR_PAGE_LIMIT = 250;
38890
39295
  const LINEAR_LIST_TOOL = "list_issues";
@@ -39136,7 +39541,7 @@ const makeLinearTaskRepository = Effect.gen(function* () {
39136
39541
  taskId
39137
39542
  });
39138
39543
  }).pipe(Effect.ignoreCause({ log: true }));
39139
- yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$1)))).pipe(Effect.forkScoped);
39544
+ yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$2)))).pipe(Effect.forkScoped);
39140
39545
  return {
39141
39546
  create,
39142
39547
  upsert,
@@ -42121,7 +42526,7 @@ const COMMON_DEV_PORTS = Object.freeze([
42121
42526
  8888,
42122
42527
  9e3
42123
42528
  ]);
42124
- const POLL_INTERVAL = Duration.seconds(3);
42529
+ const POLL_INTERVAL$1 = Duration.seconds(3);
42125
42530
  const LSOF_TIMEOUT_MS = 5e3;
42126
42531
  const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
42127
42532
  const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
@@ -42296,7 +42701,7 @@ const make$46 = Effect.gen(function* PortDiscoveryMake() {
42296
42701
  lastSnapshot: next
42297
42702
  }])) yield* broadcast(next);
42298
42703
  }, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
42299
- yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
42704
+ yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$1))));
42300
42705
  const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
42301
42706
  if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
42302
42707
  ...state,
@@ -45146,15 +45551,24 @@ const make$40 = Effect.gen(function* () {
45146
45551
  const workspacePaths = yield* WorkspacePaths;
45147
45552
  const workspaceEntries = yield* WorkspaceEntries;
45148
45553
  const readFile = Effect.fn("WorkspaceFileSystem.readFile")(function* (input) {
45149
- const target = yield* workspacePaths.resolveRelativePathWithinRoot({
45554
+ const requestedPath = "absolutePath" in input ? input.absolutePath : input.relativePath;
45555
+ const isAbsoluteRead = "absolutePath" in input;
45556
+ const target = isAbsoluteRead ? {
45557
+ absolutePath: input.absolutePath,
45558
+ relativePath: input.absolutePath
45559
+ } : yield* workspacePaths.resolveRelativePathWithinRoot({
45150
45560
  workspaceRoot: input.cwd,
45151
45561
  relativePath: input.relativePath
45152
45562
  });
45153
- const realWorkspaceRoot = yield* Effect.tryPromise({
45563
+ if (isAbsoluteRead && !path.isAbsolute(target.absolutePath)) return yield* new WorkspacePathOutsideRootError({
45564
+ workspaceRoot: input.cwd,
45565
+ relativePath: requestedPath
45566
+ });
45567
+ const realWorkspaceRoot = isAbsoluteRead ? void 0 : yield* Effect.tryPromise({
45154
45568
  try: () => NodeFSP.realpath(input.cwd),
45155
45569
  catch: (cause) => new WorkspaceFileSystemOperationError({
45156
45570
  workspaceRoot: input.cwd,
45157
- relativePath: input.relativePath,
45571
+ relativePath: requestedPath,
45158
45572
  resolvedPath: target.absolutePath,
45159
45573
  operationPath: input.cwd,
45160
45574
  operation: "realpath-workspace-root",
@@ -45165,17 +45579,17 @@ const make$40 = Effect.gen(function* () {
45165
45579
  try: () => NodeFSP.realpath(target.absolutePath),
45166
45580
  catch: (cause) => new WorkspaceFileSystemOperationError({
45167
45581
  workspaceRoot: input.cwd,
45168
- relativePath: input.relativePath,
45582
+ relativePath: requestedPath,
45169
45583
  resolvedPath: target.absolutePath,
45170
45584
  operationPath: target.absolutePath,
45171
45585
  operation: "realpath-target",
45172
45586
  cause
45173
45587
  })
45174
45588
  });
45175
- const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
45176
- if (relativeRealPath.startsWith(`..${path.sep}`) || relativeRealPath === ".." || path.isAbsolute(relativeRealPath)) return yield* new WorkspaceFilePathEscapeError({
45589
+ const relativeRealPath = realWorkspaceRoot ? path.relative(realWorkspaceRoot, realTargetPath) : void 0;
45590
+ if (realWorkspaceRoot && relativeRealPath !== void 0 && (relativeRealPath.startsWith(`..${path.sep}`) || relativeRealPath === ".." || path.isAbsolute(relativeRealPath))) return yield* new WorkspaceFilePathEscapeError({
45177
45591
  workspaceRoot: input.cwd,
45178
- relativePath: input.relativePath,
45592
+ relativePath: requestedPath,
45179
45593
  resolvedWorkspaceRoot: realWorkspaceRoot,
45180
45594
  resolvedPath: realTargetPath
45181
45595
  });
@@ -45183,7 +45597,7 @@ const make$40 = Effect.gen(function* () {
45183
45597
  try: () => NodeFSP.open(realTargetPath, "r"),
45184
45598
  catch: (cause) => new WorkspaceFileSystemOperationError({
45185
45599
  workspaceRoot: input.cwd,
45186
- relativePath: input.relativePath,
45600
+ relativePath: requestedPath,
45187
45601
  resolvedPath: realTargetPath,
45188
45602
  operationPath: realTargetPath,
45189
45603
  operation: "open",
@@ -45194,7 +45608,7 @@ const make$40 = Effect.gen(function* () {
45194
45608
  try: () => handle.stat(),
45195
45609
  catch: (cause) => new WorkspaceFileSystemOperationError({
45196
45610
  workspaceRoot: input.cwd,
45197
- relativePath: input.relativePath,
45611
+ relativePath: requestedPath,
45198
45612
  resolvedPath: realTargetPath,
45199
45613
  operationPath: realTargetPath,
45200
45614
  operation: "stat",
@@ -45203,7 +45617,7 @@ const make$40 = Effect.gen(function* () {
45203
45617
  });
45204
45618
  if (!stat.isFile()) return yield* new WorkspacePathNotFileError({
45205
45619
  workspaceRoot: input.cwd,
45206
- relativePath: input.relativePath,
45620
+ relativePath: requestedPath,
45207
45621
  resolvedPath: realTargetPath
45208
45622
  });
45209
45623
  const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
@@ -45212,7 +45626,7 @@ const make$40 = Effect.gen(function* () {
45212
45626
  try: () => handle.read(buffer, 0, bytesToRead, 0),
45213
45627
  catch: (cause) => new WorkspaceFileSystemOperationError({
45214
45628
  workspaceRoot: input.cwd,
45215
- relativePath: input.relativePath,
45629
+ relativePath: requestedPath,
45216
45630
  resolvedPath: realTargetPath,
45217
45631
  operationPath: realTargetPath,
45218
45632
  operation: "read",
@@ -45238,7 +45652,7 @@ const make$40 = Effect.gen(function* () {
45238
45652
  try: () => handle.close(),
45239
45653
  catch: (cause) => new WorkspaceFileSystemOperationError({
45240
45654
  workspaceRoot: input.cwd,
45241
- relativePath: input.relativePath,
45655
+ relativePath: requestedPath,
45242
45656
  resolvedPath: realTargetPath,
45243
45657
  operationPath: realTargetPath,
45244
45658
  operation: "close",
@@ -58619,12 +59033,17 @@ function filesystemBrowseFailureContext(error) {
58619
59033
  function projectFileFailureContext(error) {
58620
59034
  switch (error._tag) {
58621
59035
  case "WorkspacePathOutsideRootError": return { failure: "workspace_path_outside_root" };
58622
- case "WorkspaceFileSystemOperationError": return {
58623
- failure: "operation_failed",
58624
- resolvedPath: error.resolvedPath,
58625
- operation: error.operation,
58626
- operationPath: error.operationPath
58627
- };
59036
+ case "WorkspaceFileSystemOperationError":
59037
+ if (error.operation === "realpath-target" && error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT") return {
59038
+ failure: "path_not_found",
59039
+ resolvedPath: error.resolvedPath
59040
+ };
59041
+ return {
59042
+ failure: "operation_failed",
59043
+ resolvedPath: error.resolvedPath,
59044
+ operation: error.operation,
59045
+ operationPath: error.operationPath
59046
+ };
58628
59047
  case "WorkspaceFilePathEscapeError": return {
58629
59048
  failure: "resolved_path_outside_root",
58630
59049
  resolvedPath: error.resolvedPath,
@@ -58837,6 +59256,7 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
58837
59256
  const serverSelfUpdate = yield* ServerSelfUpdate;
58838
59257
  const textGeneration = yield* TextGeneration;
58839
59258
  const config = yield* ServerConfig$1;
59259
+ const allowAbsoluteFileReads = config.mode === "desktop" && !isRemoteReachableHost(config.host);
58840
59260
  const lifecycleEvents = yield* ServerLifecycleEvents;
58841
59261
  const serverSettings = yield* ServerSettingsService;
58842
59262
  const hubLink = yield* HubLink;
@@ -59545,11 +59965,17 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
59545
59965
  ...projectEntriesFailureContext(cause),
59546
59966
  cause
59547
59967
  }))), { "rpc.aggregate": "workspace" }),
59548
- [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe(Effect.mapError((cause) => new ProjectReadFileError({
59549
- ...input,
59550
- ...projectFileFailureContext(cause),
59551
- cause
59552
- }))), { "rpc.aggregate": "workspace" }),
59968
+ [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsReadFile, Effect.gen(function* () {
59969
+ if ("absolutePath" in input && !allowAbsoluteFileReads) return yield* new ProjectReadFileError({
59970
+ ...input,
59971
+ failure: "workspace_path_outside_root"
59972
+ });
59973
+ return yield* workspaceFileSystem.readFile(input).pipe(Effect.mapError((cause) => new ProjectReadFileError({
59974
+ ...input,
59975
+ ...projectFileFailureContext(cause),
59976
+ cause
59977
+ })));
59978
+ }), { "rpc.aggregate": "workspace" }),
59553
59979
  [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe(Effect.mapError((cause) => new ProjectWriteFileError({
59554
59980
  cwd: input.cwd,
59555
59981
  relativePath: input.relativePath,
@@ -63703,7 +64129,7 @@ function readScheduledWakeAt(input, completedAt) {
63703
64129
  if (typeof delaySeconds !== "number" || !Number.isFinite(delaySeconds) || delaySeconds < 0) return;
63704
64130
  const completedAtMs = Date.parse(completedAt);
63705
64131
  if (Number.isNaN(completedAtMs)) return;
63706
- return new Date(completedAtMs + delaySeconds * 1e3).toISOString();
64132
+ return DateTime.formatIso(DateTime.makeUnsafe(completedAtMs + delaySeconds * 1e3));
63707
64133
  }
63708
64134
  function isClaudeTaskTool(toolName) {
63709
64135
  return toolName === "TaskCreate" || toolName === "TaskUpdate" || toolName === "TaskList";
@@ -103781,6 +104207,142 @@ const reconcileWithNewBinary = Effect.fn("cli.selfUpdate.reconcileWithNewBinary"
103781
104207
  });
103782
104208
  });
103783
104209
  //#endregion
104210
+ //#region src/service/updateHealth.ts
104211
+ /**
104212
+ * Waiting for a restarted service to actually come back.
104213
+ *
104214
+ * `launchctl kickstart -k` returning 0 means launchd accepted the restart
104215
+ * request, not that the child booted; `systemctl restart` is the same. The
104216
+ * server already records where it is listening once its HTTP server is up, so
104217
+ * that file - written by the very process the update is about - is the honest
104218
+ * signal that the new build works.
104219
+ *
104220
+ * @module ServiceUpdateHealth
104221
+ */
104222
+ /**
104223
+ * Long enough for a cold start that has migrations to apply, short enough that
104224
+ * a broken update is reported while the operator is still watching.
104225
+ */
104226
+ const SERVICE_START_TIMEOUT = Duration.seconds(90);
104227
+ const POLL_INTERVAL = Duration.millis(500);
104228
+ /**
104229
+ * Poll until the expected build reports itself listening, the database is
104230
+ * marked for restore, or the timeout runs out.
104231
+ *
104232
+ * A restore marker ends the wait immediately: the migration guard writes it
104233
+ * only when the new build failed to migrate, and every later restart repeats
104234
+ * that failure, so there is nothing to wait for.
104235
+ */
104236
+ const awaitServiceStart = Effect.fn("awaitServiceStart")(function* (input) {
104237
+ const deadline = DateTime.addDuration(yield* DateTime.now, input.timeout ?? SERVICE_START_TIMEOUT);
104238
+ while (true) {
104239
+ const runtimeState = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath);
104240
+ if (Option.isSome(runtimeState)) {
104241
+ const state = runtimeState.value;
104242
+ const startedAt = DateTime.makeUnsafe(state.startedAt);
104243
+ if (state.cliVersion === input.expectedVersion && DateTime.isGreaterThanOrEqualTo(startedAt, input.startedAfter)) return {
104244
+ _tag: "started",
104245
+ port: state.port
104246
+ };
104247
+ }
104248
+ if (yield* databaseRestorePending(input.dbPath).pipe(Effect.orElseSucceed(() => false))) return { _tag: "migration-failed" };
104249
+ if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, deadline)) return { _tag: "timeout" };
104250
+ yield* Effect.sleep(POLL_INTERVAL);
104251
+ }
104252
+ });
104253
+ //#endregion
104254
+ //#region src/service/updateState.ts
104255
+ /**
104256
+ * ServiceUpdateState - what the last service update did, and how it ended.
104257
+ *
104258
+ * `launchctl kickstart` and `systemctl restart` both return 0 as soon as the
104259
+ * supervisor accepts the request, which says nothing about whether the child
104260
+ * actually booted. Without a record written by the update itself, a service
104261
+ * that dies on every start - a failed migration being the case this exists for
104262
+ * - looks like a successful update from the command line.
104263
+ *
104264
+ * The file is small and written atomically, so a crash mid-update leaves either
104265
+ * the previous contents or the new ones, never a half-written record.
104266
+ *
104267
+ * @module ServiceUpdateState
104268
+ */
104269
+ /**
104270
+ * - `updating`: an update is in flight; a file left in this state means the
104271
+ * updating command itself died before it could record an outcome.
104272
+ * - `rolled-back`: the new build did not come up and the previous unit was put
104273
+ * back.
104274
+ * - `failed`: the new build did not come up and nothing could be reverted -
104275
+ * the case where an in-place package upgrade left no previous build on disk.
104276
+ */
104277
+ const ServiceUpdateStatus = Schema$1.Literals([
104278
+ "idle",
104279
+ "updating",
104280
+ "rolled-back",
104281
+ "failed"
104282
+ ]);
104283
+ const ServiceUpdateState = Schema$1.Struct({
104284
+ version: Schema$1.Literal(1),
104285
+ status: ServiceUpdateStatus,
104286
+ /** Version the service is expected to be running now. */
104287
+ activeVersion: Schema$1.String,
104288
+ /** Version an in-flight update is moving to. */
104289
+ pendingVersion: Schema$1.optional(Schema$1.String),
104290
+ recordedAt: Schema$1.String,
104291
+ /** Why an update ended the way it did, for `service status` to repeat. */
104292
+ detail: Schema$1.optional(Schema$1.String)
104293
+ });
104294
+ const SERVICE_UPDATE_STATE_FILE = "service-state.json";
104295
+ const serviceUpdateStatePath = Effect.fn("serviceUpdateStatePath")(function* (baseDir) {
104296
+ return (yield* Path.Path).join(baseDir, "runtime", SERVICE_UPDATE_STATE_FILE);
104297
+ });
104298
+ const ServiceUpdateStateJson = Schema$1.fromJsonString(ServiceUpdateState);
104299
+ const decodeServiceUpdateState = Schema$1.decodeUnknownEffect(ServiceUpdateStateJson);
104300
+ const encodeServiceUpdateState = Schema$1.encodeEffect(ServiceUpdateStateJson);
104301
+ /**
104302
+ * Read the record, or none.
104303
+ *
104304
+ * Every failure reads as none: a missing file is the normal first-update case,
104305
+ * and a corrupt one must not be the reason an update refuses to run.
104306
+ */
104307
+ const readServiceUpdateState = Effect.fn("readServiceUpdateState")(function* (baseDir) {
104308
+ const fs = yield* FileSystem.FileSystem;
104309
+ const statePath = yield* serviceUpdateStatePath(baseDir);
104310
+ const contents = yield* fs.readFileString(statePath).pipe(Effect.option);
104311
+ if (Option.isNone(contents)) return Option.none();
104312
+ const trimmed = contents.value.trim();
104313
+ if (trimmed.length === 0) return Option.none();
104314
+ return yield* decodeServiceUpdateState(trimmed).pipe(Effect.map(Option.some), Effect.catchCause((cause) => Effect.logWarning("Ignoring an unreadable service update record").pipe(Effect.annotateLogs({
104315
+ statePath,
104316
+ cause
104317
+ }), Effect.as(Option.none()))));
104318
+ });
104319
+ const writeServiceUpdateState = Effect.fn("writeServiceUpdateState")(function* (input) {
104320
+ const statePath = yield* serviceUpdateStatePath(input.baseDir);
104321
+ const now = yield* DateTime.now;
104322
+ const state = {
104323
+ version: 1,
104324
+ status: input.status,
104325
+ activeVersion: input.activeVersion,
104326
+ ...input.pendingVersion === void 0 ? {} : { pendingVersion: input.pendingVersion },
104327
+ recordedAt: DateTime.formatIso(now),
104328
+ ...input.detail === void 0 ? {} : { detail: input.detail }
104329
+ };
104330
+ yield* writeFileStringAtomically({
104331
+ filePath: statePath,
104332
+ contents: `${yield* encodeServiceUpdateState(state)}\n`
104333
+ });
104334
+ return state;
104335
+ });
104336
+ /** One line for `service status`, or null when the last update went fine. */
104337
+ function describeServiceUpdateState(state) {
104338
+ switch (state.status) {
104339
+ case "idle": return null;
104340
+ case "updating": return `Last update to @p4code/cli@${state.pendingVersion ?? "unknown"} did not finish. Run \`p4c service update\` again.`;
104341
+ case "rolled-back": return `Last update to @p4code/cli@${state.pendingVersion ?? "unknown"} was rolled back to @p4code/cli@${state.activeVersion}${state.detail === void 0 ? "" : `: ${state.detail}`}`;
104342
+ case "failed": return `Last update to @p4code/cli@${state.pendingVersion ?? "unknown"} failed and could not be reverted${state.detail === void 0 ? "" : `: ${state.detail}`}`;
104343
+ }
104344
+ }
104345
+ //#endregion
103784
104346
  //#region src/cli/service.ts
103785
104347
  const isBootServiceCommandError = Schema$1.is(BootServiceCommandError);
103786
104348
  const bootServiceLayer = (config) => layer$55({
@@ -103788,6 +104350,84 @@ const bootServiceLayer = (config) => layer$55({
103788
104350
  logsDir: config.logsDir,
103789
104351
  cliVersion: version
103790
104352
  }).pipe(Layer.provide(layer$61));
104353
+ /**
104354
+ * The update installed a build that never reported itself listening.
104355
+ *
104356
+ * A typed failure rather than a printed warning: `p4c service update` exiting 0
104357
+ * after a rolled-back update is the same lie as a supervisor reporting success
104358
+ * for a process that died on start.
104359
+ */
104360
+ var ServiceUpdateNotHealthyError = class extends Schema$1.TaggedErrorClass()("ServiceUpdateNotHealthyError", {
104361
+ attemptedVersion: Schema$1.String,
104362
+ previousVersion: Schema$1.String,
104363
+ rolledBack: Schema$1.Boolean,
104364
+ detail: Schema$1.String
104365
+ }) {
104366
+ get message() {
104367
+ return this.rolledBack ? `Update to @p4code/cli@${this.attemptedVersion} was rolled back to @p4code/cli@${this.previousVersion}: ${this.detail}.` : `Update to @p4code/cli@${this.attemptedVersion} failed and could not be reverted: ${this.detail}.`;
104368
+ }
104369
+ };
104370
+ /**
104371
+ * Update the service and then wait to see whether it came back.
104372
+ *
104373
+ * `launchctl kickstart` and `systemctl restart` report that the supervisor
104374
+ * accepted the request, not that the child booted - so an update that installs
104375
+ * a build which dies on every start used to print success while the machine
104376
+ * respawned a broken server every few seconds. This waits for the new build to
104377
+ * report itself listening and puts the previous unit back if it never does.
104378
+ */
104379
+ const updateServiceGuarded = Effect.fn("cli.service.updateGuarded")(function* (input) {
104380
+ const service = yield* BootService;
104381
+ const status = yield* service.status;
104382
+ const previousUnit = yield* service.readUnit;
104383
+ const paths = yield* deriveServerPaths(status.baseDir, void 0);
104384
+ const runningState = yield* readPersistedServerRuntimeState(paths.serverRuntimeStatePath);
104385
+ const recordedState = yield* readServiceUpdateState(status.baseDir);
104386
+ const previousVersion = Option.flatMap(runningState, (state) => Option.fromNullishOr(state.cliVersion)).pipe(Option.orElse(() => Option.map(recordedState, (state) => state.activeVersion)), Option.getOrElse(() => "unknown"));
104387
+ const startedAfter = yield* DateTime.now;
104388
+ yield* writeServiceUpdateState({
104389
+ baseDir: status.baseDir,
104390
+ status: "updating",
104391
+ activeVersion: previousVersion,
104392
+ pendingVersion: input.cliVersion
104393
+ });
104394
+ const result = yield* reconcileService();
104395
+ if (!result.changed) yield* service.restart;
104396
+ const outcome = yield* awaitServiceStart({
104397
+ serverRuntimeStatePath: paths.serverRuntimeStatePath,
104398
+ dbPath: paths.dbPath,
104399
+ expectedVersion: input.cliVersion,
104400
+ startedAfter,
104401
+ ...input.timeout === void 0 ? {} : { timeout: input.timeout }
104402
+ });
104403
+ if (outcome._tag === "started") {
104404
+ yield* writeServiceUpdateState({
104405
+ baseDir: status.baseDir,
104406
+ status: "idle",
104407
+ activeVersion: input.cliVersion
104408
+ });
104409
+ return {
104410
+ _tag: "started",
104411
+ result
104412
+ };
104413
+ }
104414
+ const detail = outcome._tag === "migration-failed" ? "a database migration failed, and the database was restored to the previous schema" : "the updated service did not start in time";
104415
+ const installedUnit = yield* service.readUnit;
104416
+ yield* service.revertUnit(previousUnit);
104417
+ const rolledBack = Option.isSome(previousUnit) && Option.getOrElse(installedUnit, () => "") !== previousUnit.value;
104418
+ yield* writeServiceUpdateState({
104419
+ baseDir: status.baseDir,
104420
+ status: rolledBack ? "rolled-back" : "failed",
104421
+ activeVersion: rolledBack ? previousVersion : input.cliVersion,
104422
+ pendingVersion: input.cliVersion,
104423
+ detail
104424
+ });
104425
+ return {
104426
+ _tag: rolledBack ? "rolled-back" : "failed",
104427
+ detail,
104428
+ previousVersion
104429
+ };
104430
+ });
103791
104431
  /** Install, update, or repair the service using the CLI version running this command. */
103792
104432
  const reconcileService = Effect.fn("cli.service.reconcile")(function* () {
103793
104433
  const service = yield* BootService;
@@ -103817,7 +104457,7 @@ function formatReconcileSuccess(input) {
103817
104457
  ...input.platform === "darwin" && !input.previouslyInstalled ? [LAUNCH_AGENT_LOGIN_NOTE] : []
103818
104458
  ].join("\n");
103819
104459
  }
103820
- function formatServiceStatus(status, cliVersion) {
104460
+ function formatServiceStatus(status, cliVersion, lastUpdate) {
103821
104461
  if (!status.supported) return "P4Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
103822
104462
  if (!status.installed) return "P4Code service\n Status: not installed\n Next: Run `p4c service install`.";
103823
104463
  return [
@@ -103826,6 +104466,7 @@ function formatServiceStatus(status, cliVersion) {
103826
104466
  ` Unit: ${status.unitPath}`,
103827
104467
  ` Data: ${status.baseDir}`,
103828
104468
  ` Logs: ${status.logPath}`,
104469
+ ...lastUpdate ? [` Last update: ${lastUpdate}`] : [],
103829
104470
  ...status.current ? [] : [" Next: Run `p4c service update`."]
103830
104471
  ].join("\n");
103831
104472
  }
@@ -103891,15 +104532,24 @@ const serviceInstallCommand = Command.make("install", {
103891
104532
  tailscaleServePort: flags.tailscaleServePort
103892
104533
  }))));
103893
104534
  const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(Command.withDescription("Update or repair the background service so it runs this CLI build, then restart it."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
103894
- const result = yield* reconcileService();
103895
- if (!result.changed) {
103896
- yield* (yield* BootService).restart;
104535
+ const outcome = yield* updateServiceGuarded({ cliVersion: version });
104536
+ if (outcome._tag !== "started") {
104537
+ const service = yield* BootService;
104538
+ yield* Console.error(`Logs: ${service.logPath}`);
104539
+ return yield* new ServiceUpdateNotHealthyError({
104540
+ attemptedVersion: version,
104541
+ previousVersion: outcome.previousVersion,
104542
+ rolledBack: outcome._tag === "rolled-back",
104543
+ detail: outcome.detail
104544
+ });
104545
+ }
104546
+ if (!outcome.result.changed) {
103897
104547
  yield* Console.log(`Restarted the P4Code service on @p4code/cli@${version}.`);
103898
104548
  return;
103899
104549
  }
103900
104550
  yield* Console.log(formatReconcileSuccess({
103901
- previouslyInstalled: result.previouslyInstalled,
103902
- plan: result.plan,
104551
+ previouslyInstalled: outcome.result.previouslyInstalled,
104552
+ plan: outcome.result.plan,
103903
104553
  cliVersion: version,
103904
104554
  platform: yield* HostProcessPlatform
103905
104555
  }));
@@ -103960,8 +104610,12 @@ const serviceSelfUpdateCommand = Command.make("self-update", {
103960
104610
  yield* Console.log(`Service now runs @p4code/cli@${installed}.`);
103961
104611
  }))));
103962
104612
  const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(Command.withDescription("Show whether the P4Code background service is installed."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
103963
- const service = yield* BootService;
103964
- yield* Console.log(formatServiceStatus(yield* service.status, version));
104613
+ const status = yield* (yield* BootService).status;
104614
+ const recorded = yield* readServiceUpdateState(status.baseDir);
104615
+ yield* Console.log(formatServiceStatus(status, version, Option.match(recorded, {
104616
+ onNone: () => null,
104617
+ onSome: describeServiceUpdateState
104618
+ })));
103965
104619
  }))));
103966
104620
  Effect.gen(function* () {
103967
104621
  const { supported, installed, current } = yield* (yield* BootService).status;