@p4code/cli 0.2.3 → 0.2.5

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 +676 -48
  2. package/dist/client/assets/{DiffPanel-CyeDCyAV.js → DiffPanel-Do6aSu0E.js} +3 -3
  3. package/dist/client/assets/FilePreviewPanel-D2U3PXVf.js +2230 -0
  4. package/dist/client/assets/PreviewPanel-BuuYvjac.js +2 -0
  5. package/dist/client/assets/{PullRequestCodeTab-BPvxRrir.js → PullRequestCodeTab-vbrYmAeQ.js} +4 -4
  6. package/dist/client/assets/arrow-right-B2X51S6S.js +2 -0
  7. package/dist/client/assets/{fileCommentAnnotations-CCfWu-XF.js → fileCommentAnnotations-BZ_k09XR.js} +2 -2
  8. package/dist/client/assets/{index-BBwi3weC.js → index-IyXnER5Q.js} +1540 -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-uw3E6Jd2.js +2 -0
  20. package/dist/client/assets/terminal-links-C6S74E9U.js +47 -0
  21. package/dist/client/assets/toggle-group-CxBIRzrW.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-mAPsyPhb.js +0 -1778
  26. package/dist/client/assets/PreviewPanel-DuPIf4E8.js +0 -2
  27. package/dist/client/assets/arrow-right-CmnS08GR.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-Br6MGBfc.js +0 -2
  35. package/dist/client/assets/terminal-links-CbkAv5Ji.js +0 -47
  36. package/dist/client/assets/toggle-group-Cd2IPEFX.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.3";
240
+ var version = "0.2.5";
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) => {
@@ -998,8 +1033,10 @@ const CheckpointRef = makeEntityId("CheckpointRef");
998
1033
  * `projection_projects` row, so it never appears in project listings;
999
1034
  * clients and the server identify chat threads solely through this
1000
1035
  * constant. The value is persisted in thread rows and must never change.
1036
+ * Real projects get random UUID ids, so the readable reserved name cannot
1037
+ * collide with them.
1001
1038
  */
1002
- const P4_CHAT_PROJECT_ID = ProjectId.make("a67cca3e-9f3c-4e30-9d09-58ef21051f7a");
1039
+ const P4_CHAT_PROJECT_ID = ProjectId.make("p4-chat");
1003
1040
  const isChatProject = (projectId) => projectId === P4_CHAT_PROJECT_ID;
1004
1041
  //#endregion
1005
1042
  //#region ../../packages/contracts/src/auth.ts
@@ -8016,19 +8053,18 @@ const KeybindingsInvalidEntryIssue = Schema$1.Struct({
8016
8053
  message: TrimmedNonEmptyString,
8017
8054
  index: Schema$1.Number
8018
8055
  });
8019
- const ServerConfigIssue = Schema$1.Union([KeybindingsMalformedConfigIssue, KeybindingsInvalidEntryIssue]);
8020
- const ServerConfigIssues = Schema$1.Array(ServerConfigIssue);
8021
- const ServerProviderState = Schema$1.Literals([
8056
+ const ServerConfigIssues = ForwardCompatibleArray(Schema$1.Union([KeybindingsMalformedConfigIssue, KeybindingsInvalidEntryIssue]));
8057
+ const ServerProviderState = LiteralWithFallback([
8022
8058
  "ready",
8023
8059
  "warning",
8024
8060
  "error",
8025
8061
  "disabled"
8026
- ]);
8027
- const ServerProviderAuthStatus = Schema$1.Literals([
8062
+ ], "warning");
8063
+ const ServerProviderAuthStatus = LiteralWithFallback([
8028
8064
  "authenticated",
8029
8065
  "unauthenticated",
8030
8066
  "unknown"
8031
- ]);
8067
+ ], "unknown");
8032
8068
  const ServerProviderAuth = Schema$1.Struct({
8033
8069
  status: ServerProviderAuthStatus,
8034
8070
  type: Schema$1.optional(TrimmedNonEmptyString),
@@ -8078,11 +8114,11 @@ const ServerProviderSkill = Schema$1.Struct({
8078
8114
  */
8079
8115
  const ServerProviderAvailability = Schema$1.Literals(["available", "unavailable"]);
8080
8116
  const ServerProviderContinuation = Schema$1.Struct({ groupKey: TrimmedNonEmptyString });
8081
- const ServerProviderVersionAdvisoryStatus = Schema$1.Literals([
8117
+ const ServerProviderVersionAdvisoryStatus = LiteralWithFallback([
8082
8118
  "unknown",
8083
8119
  "current",
8084
8120
  "behind_latest"
8085
- ]);
8121
+ ], "unknown");
8086
8122
  const ServerProviderVersionAdvisory = Schema$1.Struct({
8087
8123
  status: ServerProviderVersionAdvisoryStatus,
8088
8124
  currentVersion: Schema$1.NullOr(TrimmedNonEmptyString),
@@ -8092,14 +8128,14 @@ const ServerProviderVersionAdvisory = Schema$1.Struct({
8092
8128
  checkedAt: Schema$1.NullOr(IsoDateTime),
8093
8129
  message: Schema$1.NullOr(TrimmedNonEmptyString)
8094
8130
  });
8095
- const ServerProviderUpdateStatus = Schema$1.Literals([
8131
+ const ServerProviderUpdateStatus = LiteralWithFallback([
8096
8132
  "idle",
8097
8133
  "queued",
8098
8134
  "running",
8099
8135
  "succeeded",
8100
8136
  "failed",
8101
8137
  "unchanged"
8102
- ]);
8138
+ ], "idle");
8103
8139
  const ServerProviderUpdateState = Schema$1.Struct({
8104
8140
  status: ServerProviderUpdateStatus,
8105
8141
  startedAt: Schema$1.NullOr(IsoDateTime),
@@ -8139,7 +8175,7 @@ const ServerProvider = Schema$1.Struct({
8139
8175
  versionAdvisory: Schema$1.optionalKey(ServerProviderVersionAdvisory),
8140
8176
  updateState: Schema$1.optionalKey(ServerProviderUpdateState)
8141
8177
  });
8142
- const ServerProviders = Schema$1.Array(ServerProvider);
8178
+ const ServerProviders = ForwardCompatibleArray(ServerProvider);
8143
8179
  /**
8144
8180
  * Treat the optional `availability` as "available" when absent. This is
8145
8181
  * the rule legacy producers (which omit the field) and new producers
@@ -8309,7 +8345,7 @@ const ServerConfig = Schema$1.Struct({
8309
8345
  keybindings: ResolvedKeybindingsConfig,
8310
8346
  issues: ServerConfigIssues,
8311
8347
  providers: ServerProviders,
8312
- availableEditors: Schema$1.Array(EditorId),
8348
+ availableEditors: ForwardCompatibleArray(EditorId),
8313
8349
  observability: ServerObservability,
8314
8350
  settings: ServerSettings,
8315
8351
  /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
@@ -13028,6 +13064,136 @@ const make$80 = Effect.gen(function* () {
13028
13064
  });
13029
13065
  const layer$72 = Layer.effect(PairingGrantStore, make$80).pipe(Layer.provideMerge(layer$73));
13030
13066
  //#endregion
13067
+ //#region src/persistence/DatabaseSnapshot.ts
13068
+ /**
13069
+ * DatabaseSnapshot - pre-migration snapshot of the SQLite database.
13070
+ *
13071
+ * A failed migration used to be unrecoverable: the layer that runs migrations
13072
+ * is built during startup, so a migration that throws exits the process, the
13073
+ * supervisor (launchd KeepAlive, systemd Restart=always) starts the same broken
13074
+ * build again, and `effect_sql_migrations` has already advanced past what the
13075
+ * previous release understands - so reinstalling the old CLI does not recover
13076
+ * it either.
13077
+ *
13078
+ * This module gives the migration runner something to fall back to. Before a
13079
+ * boot that has migrations to apply, the database is snapshotted with SQLite's
13080
+ * own `VACUUM INTO`, which writes one consistent file without needing the WAL
13081
+ * and shared-memory sidecars. If the migration then fails, a marker is written
13082
+ * and the process exits; the *next* boot restores the snapshot before the
13083
+ * database is opened.
13084
+ *
13085
+ * Restoring on the next boot rather than in the failing process is what makes
13086
+ * this survive a power cut: the marker is written before any live file is
13087
+ * touched, and the restore is idempotent, so an interrupted restore simply
13088
+ * resumes.
13089
+ *
13090
+ * @module DatabaseSnapshot
13091
+ */
13092
+ /** SQLite spreads a live database across the main file plus these sidecars. */
13093
+ const DB_SIDECAR_SUFFIXES = ["-wal", "-shm"];
13094
+ const BACKUP_DIR_NAME = "db-backup";
13095
+ const SNAPSHOT_FILE_NAME = "state.sqlite";
13096
+ const STAGING_FILE_NAME = "state.sqlite.staging";
13097
+ const RESTORE_MARKER_FILE_NAME = ".restore-pending";
13098
+ /**
13099
+ * One snapshot slot, beside the database it protects.
13100
+ *
13101
+ * Beside rather than under `<baseDir>/runtime` so a snapshot always lands on
13102
+ * the same filesystem as the database - `rename` is only atomic within one -
13103
+ * and so a dev run under `<baseDir>/dev` can never restore over live state.
13104
+ */
13105
+ const databaseSnapshotPaths = Effect.fn("databaseSnapshotPaths")(function* (dbPath) {
13106
+ const path = yield* Path.Path;
13107
+ const backupDir = path.join(path.dirname(dbPath), BACKUP_DIR_NAME);
13108
+ return {
13109
+ backupDir,
13110
+ snapshotPath: path.join(backupDir, SNAPSHOT_FILE_NAME),
13111
+ stagingPath: path.join(backupDir, STAGING_FILE_NAME),
13112
+ markerPath: path.join(backupDir, RESTORE_MARKER_FILE_NAME)
13113
+ };
13114
+ });
13115
+ /**
13116
+ * fsync a path so the rename or copy above it survives a power cut. Directory
13117
+ * handles cannot be opened for writing, so both files and directories are
13118
+ * opened read-only and synced through the descriptor.
13119
+ */
13120
+ const syncPath = (target) => Effect.scoped(Effect.gen(function* () {
13121
+ yield* (yield* (yield* FileSystem.FileSystem).open(target, { flag: "r" })).sync;
13122
+ }));
13123
+ /**
13124
+ * Snapshot the database unless one is already there.
13125
+ *
13126
+ * Never overwrites: after a failed migration the live database may already
13127
+ * carry half of that migration, and the existing snapshot is the only copy of
13128
+ * the schema the previous release can still read.
13129
+ */
13130
+ const captureDatabaseSnapshot = Effect.fn("captureDatabaseSnapshot")(function* (dbPath) {
13131
+ const fs = yield* FileSystem.FileSystem;
13132
+ const sql = yield* SqlClient.SqlClient;
13133
+ const paths = yield* databaseSnapshotPaths(dbPath);
13134
+ if (yield* fs.exists(paths.snapshotPath)) return;
13135
+ yield* fs.makeDirectory(paths.backupDir, { recursive: true });
13136
+ yield* fs.remove(paths.stagingPath, { force: true });
13137
+ yield* sql`VACUUM INTO ${paths.stagingPath}`;
13138
+ yield* syncPath(paths.stagingPath);
13139
+ yield* fs.rename(paths.stagingPath, paths.snapshotPath);
13140
+ yield* syncPath(paths.backupDir);
13141
+ });
13142
+ /**
13143
+ * Record that the live database must be replaced by the snapshot.
13144
+ *
13145
+ * Written before anything touches a live file, so a crash between here and the
13146
+ * restore leaves the next boot able to tell that the database is not trusted.
13147
+ */
13148
+ const markDatabaseRestorePending = Effect.fn("markDatabaseRestorePending")(function* (dbPath) {
13149
+ const fs = yield* FileSystem.FileSystem;
13150
+ const paths = yield* databaseSnapshotPaths(dbPath);
13151
+ if (yield* fs.exists(paths.markerPath)) return;
13152
+ yield* fs.makeDirectory(paths.backupDir, { recursive: true });
13153
+ yield* Effect.scoped(Effect.gen(function* () {
13154
+ yield* (yield* fs.open(paths.markerPath, { flag: "wx" })).sync;
13155
+ }));
13156
+ yield* syncPath(paths.backupDir);
13157
+ });
13158
+ const databaseRestorePending = Effect.fn("databaseRestorePending")(function* (dbPath) {
13159
+ const fs = yield* FileSystem.FileSystem;
13160
+ const paths = yield* databaseSnapshotPaths(dbPath);
13161
+ return yield* fs.exists(paths.markerPath);
13162
+ });
13163
+ /**
13164
+ * Put the snapshot back, if one is pending. Call before the database is opened.
13165
+ *
13166
+ * Idempotent by construction: the marker is cleared only after the snapshot is
13167
+ * fully copied and synced, so an interrupted restore repeats harmlessly on the
13168
+ * next boot rather than leaving a half-copied database in place.
13169
+ */
13170
+ const restoreDatabaseSnapshotIfPending = Effect.fn("restoreDatabaseSnapshotIfPending")(function* (dbPath) {
13171
+ const fs = yield* FileSystem.FileSystem;
13172
+ const path = yield* Path.Path;
13173
+ const paths = yield* databaseSnapshotPaths(dbPath);
13174
+ if (!(yield* fs.exists(paths.markerPath))) return false;
13175
+ if (!(yield* fs.exists(paths.snapshotPath))) {
13176
+ yield* fs.remove(paths.markerPath, { force: true });
13177
+ return false;
13178
+ }
13179
+ yield* fs.copyFile(paths.snapshotPath, dbPath);
13180
+ yield* syncPath(dbPath);
13181
+ for (const suffix of DB_SIDECAR_SUFFIXES) yield* fs.remove(`${dbPath}${suffix}`, { force: true });
13182
+ yield* syncPath(path.dirname(dbPath));
13183
+ yield* fs.remove(paths.markerPath, { force: true });
13184
+ yield* fs.remove(paths.snapshotPath, { force: true });
13185
+ yield* syncPath(paths.backupDir);
13186
+ return true;
13187
+ });
13188
+ /** Drop a snapshot the migrations no longer need. */
13189
+ const discardDatabaseSnapshot = Effect.fn("discardDatabaseSnapshot")(function* (dbPath) {
13190
+ const fs = yield* FileSystem.FileSystem;
13191
+ const paths = yield* databaseSnapshotPaths(dbPath);
13192
+ yield* fs.remove(paths.markerPath, { force: true });
13193
+ yield* fs.remove(paths.snapshotPath, { force: true });
13194
+ yield* fs.remove(paths.stagingPath, { force: true });
13195
+ });
13196
+ //#endregion
13031
13197
  //#region src/persistence/Migrations/001_OrchestrationEvents.ts
13032
13198
  var _001_OrchestrationEvents_default = Effect.gen(function* () {
13033
13199
  const sql = yield* SqlClient.SqlClient;
@@ -14920,6 +15086,59 @@ const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusi
14920
15086
  yield* migrations.length === 0 ? Effect.logDebug("Database schema is current") : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
14921
15087
  return executedMigrations;
14922
15088
  });
15089
+ /**
15090
+ * Highest migration already recorded in the database, or 0 for a database that
15091
+ * has never been migrated.
15092
+ *
15093
+ * Read from `sqlite_master` first because the tracking table does not exist on
15094
+ * a first boot, and a missing-table error here would be indistinguishable from
15095
+ * a real failure.
15096
+ */
15097
+ const latestAppliedMigrationId = Effect.fn("latestAppliedMigrationId")(function* () {
15098
+ const sql = yield* SqlClient.SqlClient;
15099
+ if ((yield* sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'`).length === 0) return 0;
15100
+ return (yield* sql`SELECT max(migration_id) AS id FROM effect_sql_migrations`)[0]?.id ?? 0;
15101
+ });
15102
+ /**
15103
+ * Run migrations with a snapshot to fall back to.
15104
+ *
15105
+ * Only a boot that actually has migrations to apply pays for the snapshot, so a
15106
+ * normal restart still opens the database and serves immediately.
15107
+ *
15108
+ * On failure the snapshot is deliberately *not* restored here: the database is
15109
+ * still open on this connection, and a restore under an open connection is how
15110
+ * a half-copied file gets read. The marker written instead makes the next boot
15111
+ * restore it before anything opens the database, which is also what makes an
15112
+ * interrupted restore resume rather than corrupt.
15113
+ */
15114
+ const runGuardedMigrations = Effect.fn("runGuardedMigrations")(function* (input) {
15115
+ const appliedId = yield* latestAppliedMigrationId();
15116
+ const throughId = input.options?.toMigrationInclusive;
15117
+ const targetId = migrationEntries.filter(([id]) => throughId === void 0 || id <= throughId).reduce((highest, [id]) => id > highest ? id : highest, 0);
15118
+ if (targetId <= appliedId) {
15119
+ yield* discardSnapshotQuietly(input.dbPath);
15120
+ return yield* runMigrations(input.options);
15121
+ }
15122
+ yield* Effect.log("Snapshotting the database before migrations").pipe(Effect.annotateLogs({
15123
+ appliedMigrationId: appliedId,
15124
+ targetMigrationId: targetId
15125
+ }));
15126
+ yield* captureDatabaseSnapshot(input.dbPath).pipe(Effect.tapCause((cause) => Effect.logError("Refusing to migrate without a database snapshot").pipe(Effect.annotateLogs({
15127
+ dbPath: input.dbPath,
15128
+ cause
15129
+ }))));
15130
+ 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({
15131
+ appliedMigrationId: appliedId,
15132
+ targetMigrationId: targetId
15133
+ }))), Effect.tapError((cause) => Effect.logError("Could not mark the database for restore").pipe(Effect.annotateLogs({ cause }))), Effect.ignore)));
15134
+ yield* discardSnapshotQuietly(input.dbPath);
15135
+ return executed;
15136
+ });
15137
+ /**
15138
+ * A snapshot that cannot be deleted is wasted disk, not a reason to refuse to
15139
+ * start - the schema it protected is already applied by the time this runs.
15140
+ */
15141
+ const discardSnapshotQuietly = (dbPath) => discardDatabaseSnapshot(dbPath).pipe(Effect.tapError((cause) => Effect.logWarning("Could not discard the database snapshot").pipe(Effect.annotateLogs({ cause }))), Effect.ignore);
14923
15142
  Layer.effectDiscard(runMigrations());
14924
15143
  //#endregion
14925
15144
  //#region src/persistence/Layers/Sqlite.ts
@@ -14932,17 +15151,27 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (co
14932
15151
  const loader = defaultSqliteClientLoaders[runtime];
14933
15152
  return (yield* Effect.promise(loader)).layer(config);
14934
15153
  }, Layer.unwrap);
14935
- const setup$1 = Layer.effectDiscard(Effect.gen(function* () {
15154
+ const applyPragmas = Effect.gen(function* () {
14936
15155
  const sql = yield* SqlClient.SqlClient;
14937
15156
  yield* sql`PRAGMA journal_mode = WAL;`;
14938
15157
  yield* sql`PRAGMA foreign_keys = ON;`;
14939
- yield* runMigrations();
14940
- }));
15158
+ });
15159
+ const setupFile = (dbPath) => Layer.effectDiscard(applyPragmas.pipe(Effect.andThen(runGuardedMigrations({ dbPath }))));
15160
+ /**
15161
+ * The in-memory database used by tests has no file to snapshot and nothing that
15162
+ * survives the process to restore.
15163
+ *
15164
+ * A separate layer rather than a nullable path, so the memory setup keeps
15165
+ * needing nothing but `SqlClient`: sharing one function would put the file
15166
+ * path's `FileSystem | Path` requirement into every test layer built on it.
15167
+ */
15168
+ const setupMemory = Layer.effectDiscard(applyPragmas.pipe(Effect.andThen(runMigrations())));
14941
15169
  const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath) {
14942
15170
  const fs = yield* FileSystem.FileSystem;
14943
15171
  const path = yield* Path.Path;
14944
15172
  yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
14945
- return Layer.provideMerge(setup$1, makeRuntimeSqliteLayer({
15173
+ yield* restoreDatabaseSnapshotIfPending(dbPath).pipe(Effect.tap((restored) => restored ? Effect.logWarning("Restored the database snapshot left by a failed migration") : Effect.void));
15174
+ return Layer.provideMerge(setupFile(dbPath), makeRuntimeSqliteLayer({
14946
15175
  filename: dbPath,
14947
15176
  spanAttributes: {
14948
15177
  "db.name": path.basename(dbPath),
@@ -14950,7 +15179,7 @@ const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(functio
14950
15179
  }
14951
15180
  }));
14952
15181
  }, Layer.unwrap);
14953
- Layer.provideMerge(setup$1, makeRuntimeSqliteLayer({ filename: ":memory:" }));
15182
+ Layer.provideMerge(setupMemory, makeRuntimeSqliteLayer({ filename: ":memory:" }));
14954
15183
  const layerConfig = Layer.unwrap(Effect.map(Effect.service(ServerConfig$1), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)));
14955
15184
  const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
14956
15185
  const serverAuthInternalErrorContext = { cause: Schema$1.Defect() };
@@ -22614,7 +22843,7 @@ const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(funct
22614
22843
  };
22615
22844
  }
22616
22845
  case "thread.create":
22617
- yield* requireProject({
22846
+ if (!isChatProject(command.projectId)) yield* requireProject({
22618
22847
  readModel,
22619
22848
  command,
22620
22849
  projectId: command.projectId
@@ -29911,11 +30140,14 @@ const make$64 = Effect.fn("cloud.boot_service.make")(function* (input) {
29911
30140
  baseDir: input.baseDir
29912
30141
  };
29913
30142
  }).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })), Effect.withSpan("cloud.boot_service.status"));
30143
+ 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"));
29914
30144
  return BootService.of({
29915
30145
  install,
29916
30146
  uninstall,
29917
30147
  restart,
29918
30148
  status,
30149
+ readUnit,
30150
+ revertUnit: rollbackFailedInstall,
29919
30151
  logPath
29920
30152
  });
29921
30153
  });
@@ -31568,7 +31800,15 @@ const PersistedServerRuntimeState = Schema$1.Struct({
31568
31800
  host: Schema$1.optional(Schema$1.String),
31569
31801
  port: Schema$1.Int,
31570
31802
  origin: Schema$1.String,
31571
- startedAt: Schema$1.String
31803
+ startedAt: Schema$1.String,
31804
+ /**
31805
+ * Which build wrote this. `p4c service update` restarts the service and then
31806
+ * has to tell whether what came back is the new build or the old one still
31807
+ * running - the pid alone cannot say that.
31808
+ *
31809
+ * Optional so a file written by an older server still decodes.
31810
+ */
31811
+ cliVersion: Schema$1.optional(Schema$1.String)
31572
31812
  });
31573
31813
  var ServerRuntimeStateError = class extends Schema$1.TaggedErrorClass()("ServerRuntimeStateError", {
31574
31814
  operation: Schema$1.Literals([
@@ -31594,7 +31834,8 @@ const makePersistedServerRuntimeState = (input) => Effect.map(DateTime.now, (now
31594
31834
  ...input.config.host ? { host: input.config.host } : {},
31595
31835
  port: input.port,
31596
31836
  origin: runtimeOriginForConfig(input.config, input.port),
31597
- startedAt: DateTime.formatIso(now)
31837
+ startedAt: DateTime.formatIso(now),
31838
+ cliVersion: version
31598
31839
  }));
31599
31840
  const persistServerRuntimeState = (input) => writeFileStringAtomically({
31600
31841
  filePath: input.path,
@@ -33012,7 +33253,8 @@ const ICON_SOURCE_FILES = [
33012
33253
  "src/index.html"
33013
33254
  ];
33014
33255
  const LINK_ICON_HTML_RE = /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
33015
- const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
33256
+ const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
33257
+ const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
33016
33258
  var ProjectFaviconResolutionError = class extends Schema$1.TaggedErrorClass()("ProjectFaviconResolutionError", {
33017
33259
  operation: Schema$1.Literals([
33018
33260
  "normalize-workspace",
@@ -33034,8 +33276,11 @@ var ProjectFaviconResolver = class extends Context.Service()("@p4code/cli/projec
33034
33276
  function extractIconHref(source) {
33035
33277
  const htmlMatch = source.match(LINK_ICON_HTML_RE);
33036
33278
  if (htmlMatch?.[1]) return htmlMatch[1];
33037
- const objMatch = source.match(LINK_ICON_OBJ_RE);
33038
- if (objMatch?.[1]) return objMatch[1];
33279
+ for (const run of source.split("}")) {
33280
+ if (!ICON_REL_RE.test(run)) continue;
33281
+ const hrefMatch = run.match(ICON_HREF_RE);
33282
+ if (hrefMatch?.[1]) return hrefMatch[1];
33283
+ }
33039
33284
  return null;
33040
33285
  }
33041
33286
  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) }));
@@ -33940,7 +34185,16 @@ Object.freeze({
33940
34185
  fromEnd: 0
33941
34186
  });
33942
34187
  //#endregion
33943
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/cleanLastNewline.js
34188
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/getHunkSideBoundaries.js
34189
+ /** Converts a unified hunk side's start/count into its consumed-file range. */
34190
+ function getHunkSideStartBoundary(start, count) {
34191
+ return start - (count === 0 ? 0 : 1);
34192
+ }
34193
+ function getHunkSideEndBoundary(start, count) {
34194
+ return getHunkSideStartBoundary(start, count) + count;
34195
+ }
34196
+ //#endregion
34197
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/cleanLastNewline.js
33944
34198
  function cleanLastNewline(contents) {
33945
34199
  let end = contents.length;
33946
34200
  if (contents.charCodeAt(end - 1) === 10) {
@@ -33950,7 +34204,7 @@ function cleanLastNewline(contents) {
33950
34204
  return contents.slice(0, end);
33951
34205
  }
33952
34206
  //#endregion
33953
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/detachString.js
34207
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/detachString.js
33954
34208
  const stringDetachEncoder = new TextEncoder();
33955
34209
  const stringDetachDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
33956
34210
  const SURROGATE_CODE_UNIT_PATTERN = /[\uD800-\uDFFF]/;
@@ -33968,7 +34222,152 @@ function detachString(value) {
33968
34222
  return stringDetachDecoder.decode(stringDetachBuffer.subarray(0, written));
33969
34223
  }
33970
34224
  //#endregion
33971
- //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.0-beta.5_patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b3_3b51331382fbad9d080c5033ee1e2a6a/node_modules/@pierre/diffs/dist/utils/parsePatchFiles.js
34225
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/realignChangeContent.js
34226
+ const MAX_ALIGNMENT_COMPARISONS = 4096;
34227
+ const MIN_IMPROVEMENT_PER_PAIR = .5;
34228
+ /**
34229
+ * Re-split count-mismatched change blocks in every hunk so paired lines are
34230
+ * chosen by content similarity instead of position, then slide blank-line
34231
+ * insert/delete blocks to the top of their blank run. Mutates `hunks` in
34232
+ * place; rendered row counts are unchanged (a split block covers the same
34233
+ * split/unified rows as the original).
34234
+ */
34235
+ function realignChangeContentBySimilarity(diff) {
34236
+ for (const hunk of diff.hunks) {
34237
+ for (let index = 0; index < hunk.hunkContent.length; index++) {
34238
+ const content = hunk.hunkContent[index];
34239
+ if (content.type !== "change") continue;
34240
+ const replacement = realignChangeBlock(diff, content);
34241
+ if (replacement != null) {
34242
+ hunk.hunkContent.splice(index, 1, ...replacement);
34243
+ index += replacement.length - 1;
34244
+ }
34245
+ }
34246
+ slideBlankBoundaryBlocksUp(hunk, diff);
34247
+ }
34248
+ }
34249
+ /**
34250
+ * Slide pure insert/delete blocks made entirely of blank lines to the top of
34251
+ * the blank run they sit in. Adding or removing a blank line next to
34252
+ * existing blanks is ambiguous, and the diff library reports the change at
34253
+ * the run's bottom — so pressing Enter at the end of a line marks a blank
34254
+ * *below* the caret as inserted while the caret's own new line renders as
34255
+ * context. Sliding up anchors the change to the content above it (the caret
34256
+ * line after an Enter) instead.
34257
+ *
34258
+ * The slide is all-or-nothing: it only applies when the block comes to rest
34259
+ * directly beneath remaining in-hunk content. A slide that would consume the
34260
+ * hunk's entire leading context was stopped by the hunk's edge — a context
34261
+ * window cut, not the top of the blank run — and that landing spot is
34262
+ * arbitrary, so the block keeps the library's bottom-of-run anchor (which
34263
+ * sits against the content below the run). Non-blank blocks never slide, so
34264
+ * code that merely ends like its neighbor (an added function before an
34265
+ * identical `}`) keeps the library's canonical position.
34266
+ */
34267
+ function slideBlankBoundaryBlocksUp(hunk, diff) {
34268
+ const { hunkContent } = hunk;
34269
+ for (let index = 1; index < hunkContent.length; index++) {
34270
+ const block = hunkContent[index];
34271
+ const previous = hunkContent[index - 1];
34272
+ if (block.type !== "change" || block.additions > 0 && block.deletions > 0 || previous.type !== "context") continue;
34273
+ const isInsert = block.additions > 0;
34274
+ const lines = isInsert ? diff.additionLines : diff.deletionLines;
34275
+ const blockStart = isInsert ? block.additionLineIndex : block.deletionLineIndex;
34276
+ const blockLength = isInsert ? block.additions : block.deletions;
34277
+ const unit = lines[blockStart] ?? "";
34278
+ if (unit.trim() !== "") continue;
34279
+ let uniform = true;
34280
+ for (let offset = 1; offset < blockLength; offset++) if (lines[blockStart + offset] !== unit) {
34281
+ uniform = false;
34282
+ break;
34283
+ }
34284
+ if (!uniform) continue;
34285
+ let slide = 0;
34286
+ while (slide < previous.lines && diff.additionLines[previous.additionLineIndex + previous.lines - 1 - slide] === unit) slide++;
34287
+ if (slide === 0) continue;
34288
+ if (index === 1 && slide === previous.lines) continue;
34289
+ block.additionLineIndex -= slide;
34290
+ block.deletionLineIndex -= slide;
34291
+ const blockAdditionEnd = block.additionLineIndex + block.additions;
34292
+ const blockDeletionEnd = block.deletionLineIndex + block.deletions;
34293
+ const next = hunkContent[index + 1];
34294
+ if (next?.type === "context") {
34295
+ next.lines += slide;
34296
+ next.additionLineIndex = blockAdditionEnd;
34297
+ next.deletionLineIndex = blockDeletionEnd;
34298
+ } else hunkContent.splice(index + 1, 0, {
34299
+ type: "context",
34300
+ lines: slide,
34301
+ additionLineIndex: blockAdditionEnd,
34302
+ deletionLineIndex: blockDeletionEnd
34303
+ });
34304
+ previous.lines -= slide;
34305
+ if (previous.lines === 0) {
34306
+ hunkContent.splice(index - 1, 1);
34307
+ index--;
34308
+ }
34309
+ }
34310
+ }
34311
+ function realignChangeBlock(diff, content) {
34312
+ const { deletions, additions, deletionLineIndex, additionLineIndex } = content;
34313
+ const pairCount = Math.min(deletions, additions);
34314
+ const surplus = Math.abs(additions - deletions);
34315
+ if (pairCount === 0 || surplus === 0 || pairCount * (surplus + 1) > MAX_ALIGNMENT_COMPARISONS) return null;
34316
+ const strippedDeletions = [];
34317
+ for (let line = 0; line < deletions; line++) strippedDeletions.push(stripWhitespace(diff.deletionLines[deletionLineIndex + line] ?? ""));
34318
+ const strippedAdditions = [];
34319
+ for (let line = 0; line < additions; line++) strippedAdditions.push(stripWhitespace(diff.additionLines[additionLineIndex + line] ?? ""));
34320
+ const additionsAreLonger = additions > deletions;
34321
+ let bestOffset = 0;
34322
+ let bestScore = -1;
34323
+ for (let offset = 0; offset <= surplus; offset++) {
34324
+ let score = 0;
34325
+ for (let pair = 0; pair < pairCount; pair++) score += lineSimilarity(strippedDeletions[pair + (additionsAreLonger ? 0 : offset)], strippedAdditions[pair + (additionsAreLonger ? offset : 0)]);
34326
+ if (offset === 0) bestScore = score + pairCount * MIN_IMPROVEMENT_PER_PAIR;
34327
+ else if (score > bestScore) {
34328
+ bestScore = score;
34329
+ bestOffset = offset;
34330
+ }
34331
+ }
34332
+ if (bestOffset === 0) return null;
34333
+ const blocks = [];
34334
+ const pushBlock = (blockDeletions, blockAdditions, blockDeletionIndex, blockAdditionIndex) => {
34335
+ if (blockDeletions > 0 || blockAdditions > 0) blocks.push({
34336
+ type: "change",
34337
+ deletions: blockDeletions,
34338
+ additions: blockAdditions,
34339
+ deletionLineIndex: blockDeletionIndex,
34340
+ additionLineIndex: blockAdditionIndex
34341
+ });
34342
+ };
34343
+ if (additionsAreLonger) {
34344
+ pushBlock(0, bestOffset, deletionLineIndex, additionLineIndex);
34345
+ pushBlock(pairCount, pairCount, deletionLineIndex, additionLineIndex + bestOffset);
34346
+ pushBlock(0, additions - pairCount - bestOffset, deletionLineIndex + pairCount, additionLineIndex + bestOffset + pairCount);
34347
+ } else {
34348
+ pushBlock(bestOffset, 0, deletionLineIndex, additionLineIndex);
34349
+ pushBlock(pairCount, pairCount, deletionLineIndex + bestOffset, additionLineIndex);
34350
+ pushBlock(deletions - pairCount - bestOffset, 0, deletionLineIndex + bestOffset + pairCount, additionLineIndex + pairCount);
34351
+ }
34352
+ return blocks;
34353
+ }
34354
+ const WHITESPACE = /\s+/g;
34355
+ function stripWhitespace(line) {
34356
+ return line.replace(WHITESPACE, "");
34357
+ }
34358
+ function lineSimilarity(a, b) {
34359
+ if (a === b) return 1;
34360
+ const maxLength = Math.max(a.length, b.length);
34361
+ const minLength = Math.min(a.length, b.length);
34362
+ if (minLength === 0) return 0;
34363
+ let prefix = 0;
34364
+ while (prefix < minLength && a[prefix] === b[prefix]) prefix++;
34365
+ let suffix = 0;
34366
+ while (suffix < minLength - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
34367
+ return (prefix + suffix) / maxLength;
34368
+ }
34369
+ //#endregion
34370
+ //#region ../../node_modules/.pnpm/@pierre+diffs@1.3.5_patch_hash=ab83d1300a2b500057de1efef105e40b3766e14e1859523715f5205d_75eb9e5f35bdb1c1e149107bc7dcf802/node_modules/@pierre/diffs/dist/utils/parsePatchFiles.js
33972
34371
  function processPatch(data, cacheKeyPrefix, throwOnError) {
33973
34372
  try {
33974
34373
  return _processPatch(data, cacheKeyPrefix, throwOnError);
@@ -34044,9 +34443,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34044
34443
  if (currentFile.deletionLines.length === 1 && oldFile?.contents === "") currentFile.deletionLines.length = 0;
34045
34444
  for (const line of lines) {
34046
34445
  if (line.startsWith("diff --git")) {
34047
- const filenameMatch$1 = line.trim().match(ALTERNATE_FILE_NAMES_GIT);
34048
- const prevName = filenameMatch$1?.[1] ?? filenameMatch$1?.[2];
34049
- const name = filenameMatch$1?.[3] ?? filenameMatch$1?.[4];
34446
+ const filenameMatch = line.trim().match(ALTERNATE_FILE_NAMES_GIT);
34447
+ const prevName = filenameMatch?.[1] ?? filenameMatch?.[2];
34448
+ const name = filenameMatch?.[3] ?? filenameMatch?.[4];
34050
34449
  if (prevName == null || name == null) {
34051
34450
  if (throwOnError) throw Error("parsePatchContent: invalid git diff header");
34052
34451
  else console.error("parsePatchContent: invalid git diff header", line);
@@ -34193,9 +34592,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34193
34592
  if (throwOnError && (parsedAdditionLines !== hunkData.additionCount || parsedDeletionLines !== hunkData.deletionCount)) throw Error("parsePatchContent: hunk line count mismatch");
34194
34593
  hunkData.additionLines = additionLines;
34195
34594
  hunkData.deletionLines = deletionLines;
34196
- hunkData.collapsedBefore = Math.max(hunkData.additionStart - 1 - lastHunkEnd, 0);
34595
+ hunkData.collapsedBefore = Math.max(getHunkSideStartBoundary(hunkData.additionStart, hunkData.additionCount) - lastHunkEnd, 0);
34197
34596
  currentFile.hunks.push(hunkData);
34198
- lastHunkEnd = hunkData.additionStart + hunkData.additionCount - 1;
34597
+ lastHunkEnd = getHunkSideEndBoundary(hunkData.additionStart, hunkData.additionCount);
34199
34598
  for (const content of hunkData.hunkContent) if (content.type === "context") {
34200
34599
  hunkData.splitLineCount += content.lines;
34201
34600
  hunkData.unifiedLineCount += content.lines;
@@ -34212,9 +34611,9 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34212
34611
  if (throwOnError && isPartial && !isGitDiff && currentFile.hunks.length === 0) throw Error("parsePatchContent: unified file has no hunks");
34213
34612
  if (currentFile.hunks.length > 0 && !isPartial && currentFile.additionLines.length > 0 && currentFile.deletionLines.length > 0) {
34214
34613
  const lastHunk = currentFile.hunks[currentFile.hunks.length - 1];
34215
- const lastHunkEnd$1 = lastHunk.additionStart + lastHunk.additionCount - 1;
34614
+ const lastHunkEnd = getHunkSideEndBoundary(lastHunk.additionStart, lastHunk.additionCount);
34216
34615
  const totalFileLines = currentFile.additionLines.length;
34217
- const collapsedAfter = Math.max(totalFileLines - lastHunkEnd$1, 0);
34616
+ const collapsedAfter = Math.max(totalFileLines - lastHunkEnd, 0);
34218
34617
  currentFile.splitLineCount += collapsedAfter;
34219
34618
  currentFile.unifiedLineCount += collapsedAfter;
34220
34619
  }
@@ -34225,6 +34624,7 @@ function _processFile(fileDiffString, { cacheKey, isGitDiff = GIT_DIFF_FILE_BREA
34225
34624
  else if (oldFile != null && oldFile.contents !== "" && (newFile == null || newFile.contents === "")) currentFile.type = "deleted";
34226
34625
  }
34227
34626
  if (currentFile.type !== "rename-pure" && currentFile.type !== "rename-changed") currentFile.prevName = void 0;
34627
+ realignChangeContentBySimilarity(currentFile);
34228
34628
  return currentFile;
34229
34629
  }
34230
34630
  /**
@@ -38882,7 +39282,7 @@ const LINEAR_ISSUE_FIELDS = [
38882
39282
  * a Linear workspace is edited by people at human speed rather than by agents
38883
39283
  * at machine speed.
38884
39284
  */
38885
- const POLL_INTERVAL$1 = Duration.seconds(30);
39285
+ const POLL_INTERVAL$2 = Duration.seconds(30);
38886
39286
  /** Linear's cap on one page. Asking for more is an error, not a bigger page. */
38887
39287
  const LINEAR_PAGE_LIMIT = 250;
38888
39288
  const LINEAR_LIST_TOOL = "list_issues";
@@ -39134,7 +39534,7 @@ const makeLinearTaskRepository = Effect.gen(function* () {
39134
39534
  taskId
39135
39535
  });
39136
39536
  }).pipe(Effect.ignoreCause({ log: true }));
39137
- yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$1)))).pipe(Effect.forkScoped);
39537
+ yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$2)))).pipe(Effect.forkScoped);
39138
39538
  return {
39139
39539
  create,
39140
39540
  upsert,
@@ -42119,7 +42519,7 @@ const COMMON_DEV_PORTS = Object.freeze([
42119
42519
  8888,
42120
42520
  9e3
42121
42521
  ]);
42122
- const POLL_INTERVAL = Duration.seconds(3);
42522
+ const POLL_INTERVAL$1 = Duration.seconds(3);
42123
42523
  const LSOF_TIMEOUT_MS = 5e3;
42124
42524
  const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
42125
42525
  const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
@@ -42294,7 +42694,7 @@ const make$46 = Effect.gen(function* PortDiscoveryMake() {
42294
42694
  lastSnapshot: next
42295
42695
  }])) yield* broadcast(next);
42296
42696
  }, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
42297
- yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
42697
+ yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$1))));
42298
42698
  const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
42299
42699
  if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
42300
42700
  ...state,
@@ -63701,7 +64101,7 @@ function readScheduledWakeAt(input, completedAt) {
63701
64101
  if (typeof delaySeconds !== "number" || !Number.isFinite(delaySeconds) || delaySeconds < 0) return;
63702
64102
  const completedAtMs = Date.parse(completedAt);
63703
64103
  if (Number.isNaN(completedAtMs)) return;
63704
- return new Date(completedAtMs + delaySeconds * 1e3).toISOString();
64104
+ return DateTime.formatIso(DateTime.makeUnsafe(completedAtMs + delaySeconds * 1e3));
63705
64105
  }
63706
64106
  function isClaudeTaskTool(toolName) {
63707
64107
  return toolName === "TaskCreate" || toolName === "TaskUpdate" || toolName === "TaskList";
@@ -103779,6 +104179,142 @@ const reconcileWithNewBinary = Effect.fn("cli.selfUpdate.reconcileWithNewBinary"
103779
104179
  });
103780
104180
  });
103781
104181
  //#endregion
104182
+ //#region src/service/updateHealth.ts
104183
+ /**
104184
+ * Waiting for a restarted service to actually come back.
104185
+ *
104186
+ * `launchctl kickstart -k` returning 0 means launchd accepted the restart
104187
+ * request, not that the child booted; `systemctl restart` is the same. The
104188
+ * server already records where it is listening once its HTTP server is up, so
104189
+ * that file - written by the very process the update is about - is the honest
104190
+ * signal that the new build works.
104191
+ *
104192
+ * @module ServiceUpdateHealth
104193
+ */
104194
+ /**
104195
+ * Long enough for a cold start that has migrations to apply, short enough that
104196
+ * a broken update is reported while the operator is still watching.
104197
+ */
104198
+ const SERVICE_START_TIMEOUT = Duration.seconds(90);
104199
+ const POLL_INTERVAL = Duration.millis(500);
104200
+ /**
104201
+ * Poll until the expected build reports itself listening, the database is
104202
+ * marked for restore, or the timeout runs out.
104203
+ *
104204
+ * A restore marker ends the wait immediately: the migration guard writes it
104205
+ * only when the new build failed to migrate, and every later restart repeats
104206
+ * that failure, so there is nothing to wait for.
104207
+ */
104208
+ const awaitServiceStart = Effect.fn("awaitServiceStart")(function* (input) {
104209
+ const deadline = DateTime.addDuration(yield* DateTime.now, input.timeout ?? SERVICE_START_TIMEOUT);
104210
+ while (true) {
104211
+ const runtimeState = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath);
104212
+ if (Option.isSome(runtimeState)) {
104213
+ const state = runtimeState.value;
104214
+ const startedAt = DateTime.makeUnsafe(state.startedAt);
104215
+ if (state.cliVersion === input.expectedVersion && DateTime.isGreaterThanOrEqualTo(startedAt, input.startedAfter)) return {
104216
+ _tag: "started",
104217
+ port: state.port
104218
+ };
104219
+ }
104220
+ if (yield* databaseRestorePending(input.dbPath).pipe(Effect.orElseSucceed(() => false))) return { _tag: "migration-failed" };
104221
+ if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, deadline)) return { _tag: "timeout" };
104222
+ yield* Effect.sleep(POLL_INTERVAL);
104223
+ }
104224
+ });
104225
+ //#endregion
104226
+ //#region src/service/updateState.ts
104227
+ /**
104228
+ * ServiceUpdateState - what the last service update did, and how it ended.
104229
+ *
104230
+ * `launchctl kickstart` and `systemctl restart` both return 0 as soon as the
104231
+ * supervisor accepts the request, which says nothing about whether the child
104232
+ * actually booted. Without a record written by the update itself, a service
104233
+ * that dies on every start - a failed migration being the case this exists for
104234
+ * - looks like a successful update from the command line.
104235
+ *
104236
+ * The file is small and written atomically, so a crash mid-update leaves either
104237
+ * the previous contents or the new ones, never a half-written record.
104238
+ *
104239
+ * @module ServiceUpdateState
104240
+ */
104241
+ /**
104242
+ * - `updating`: an update is in flight; a file left in this state means the
104243
+ * updating command itself died before it could record an outcome.
104244
+ * - `rolled-back`: the new build did not come up and the previous unit was put
104245
+ * back.
104246
+ * - `failed`: the new build did not come up and nothing could be reverted -
104247
+ * the case where an in-place package upgrade left no previous build on disk.
104248
+ */
104249
+ const ServiceUpdateStatus = Schema$1.Literals([
104250
+ "idle",
104251
+ "updating",
104252
+ "rolled-back",
104253
+ "failed"
104254
+ ]);
104255
+ const ServiceUpdateState = Schema$1.Struct({
104256
+ version: Schema$1.Literal(1),
104257
+ status: ServiceUpdateStatus,
104258
+ /** Version the service is expected to be running now. */
104259
+ activeVersion: Schema$1.String,
104260
+ /** Version an in-flight update is moving to. */
104261
+ pendingVersion: Schema$1.optional(Schema$1.String),
104262
+ recordedAt: Schema$1.String,
104263
+ /** Why an update ended the way it did, for `service status` to repeat. */
104264
+ detail: Schema$1.optional(Schema$1.String)
104265
+ });
104266
+ const SERVICE_UPDATE_STATE_FILE = "service-state.json";
104267
+ const serviceUpdateStatePath = Effect.fn("serviceUpdateStatePath")(function* (baseDir) {
104268
+ return (yield* Path.Path).join(baseDir, "runtime", SERVICE_UPDATE_STATE_FILE);
104269
+ });
104270
+ const ServiceUpdateStateJson = Schema$1.fromJsonString(ServiceUpdateState);
104271
+ const decodeServiceUpdateState = Schema$1.decodeUnknownEffect(ServiceUpdateStateJson);
104272
+ const encodeServiceUpdateState = Schema$1.encodeEffect(ServiceUpdateStateJson);
104273
+ /**
104274
+ * Read the record, or none.
104275
+ *
104276
+ * Every failure reads as none: a missing file is the normal first-update case,
104277
+ * and a corrupt one must not be the reason an update refuses to run.
104278
+ */
104279
+ const readServiceUpdateState = Effect.fn("readServiceUpdateState")(function* (baseDir) {
104280
+ const fs = yield* FileSystem.FileSystem;
104281
+ const statePath = yield* serviceUpdateStatePath(baseDir);
104282
+ const contents = yield* fs.readFileString(statePath).pipe(Effect.option);
104283
+ if (Option.isNone(contents)) return Option.none();
104284
+ const trimmed = contents.value.trim();
104285
+ if (trimmed.length === 0) return Option.none();
104286
+ return yield* decodeServiceUpdateState(trimmed).pipe(Effect.map(Option.some), Effect.catchCause((cause) => Effect.logWarning("Ignoring an unreadable service update record").pipe(Effect.annotateLogs({
104287
+ statePath,
104288
+ cause
104289
+ }), Effect.as(Option.none()))));
104290
+ });
104291
+ const writeServiceUpdateState = Effect.fn("writeServiceUpdateState")(function* (input) {
104292
+ const statePath = yield* serviceUpdateStatePath(input.baseDir);
104293
+ const now = yield* DateTime.now;
104294
+ const state = {
104295
+ version: 1,
104296
+ status: input.status,
104297
+ activeVersion: input.activeVersion,
104298
+ ...input.pendingVersion === void 0 ? {} : { pendingVersion: input.pendingVersion },
104299
+ recordedAt: DateTime.formatIso(now),
104300
+ ...input.detail === void 0 ? {} : { detail: input.detail }
104301
+ };
104302
+ yield* writeFileStringAtomically({
104303
+ filePath: statePath,
104304
+ contents: `${yield* encodeServiceUpdateState(state)}\n`
104305
+ });
104306
+ return state;
104307
+ });
104308
+ /** One line for `service status`, or null when the last update went fine. */
104309
+ function describeServiceUpdateState(state) {
104310
+ switch (state.status) {
104311
+ case "idle": return null;
104312
+ case "updating": return `Last update to @p4code/cli@${state.pendingVersion ?? "unknown"} did not finish. Run \`p4c service update\` again.`;
104313
+ 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}`}`;
104314
+ case "failed": return `Last update to @p4code/cli@${state.pendingVersion ?? "unknown"} failed and could not be reverted${state.detail === void 0 ? "" : `: ${state.detail}`}`;
104315
+ }
104316
+ }
104317
+ //#endregion
103782
104318
  //#region src/cli/service.ts
103783
104319
  const isBootServiceCommandError = Schema$1.is(BootServiceCommandError);
103784
104320
  const bootServiceLayer = (config) => layer$55({
@@ -103786,6 +104322,84 @@ const bootServiceLayer = (config) => layer$55({
103786
104322
  logsDir: config.logsDir,
103787
104323
  cliVersion: version
103788
104324
  }).pipe(Layer.provide(layer$61));
104325
+ /**
104326
+ * The update installed a build that never reported itself listening.
104327
+ *
104328
+ * A typed failure rather than a printed warning: `p4c service update` exiting 0
104329
+ * after a rolled-back update is the same lie as a supervisor reporting success
104330
+ * for a process that died on start.
104331
+ */
104332
+ var ServiceUpdateNotHealthyError = class extends Schema$1.TaggedErrorClass()("ServiceUpdateNotHealthyError", {
104333
+ attemptedVersion: Schema$1.String,
104334
+ previousVersion: Schema$1.String,
104335
+ rolledBack: Schema$1.Boolean,
104336
+ detail: Schema$1.String
104337
+ }) {
104338
+ get message() {
104339
+ 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}.`;
104340
+ }
104341
+ };
104342
+ /**
104343
+ * Update the service and then wait to see whether it came back.
104344
+ *
104345
+ * `launchctl kickstart` and `systemctl restart` report that the supervisor
104346
+ * accepted the request, not that the child booted - so an update that installs
104347
+ * a build which dies on every start used to print success while the machine
104348
+ * respawned a broken server every few seconds. This waits for the new build to
104349
+ * report itself listening and puts the previous unit back if it never does.
104350
+ */
104351
+ const updateServiceGuarded = Effect.fn("cli.service.updateGuarded")(function* (input) {
104352
+ const service = yield* BootService;
104353
+ const status = yield* service.status;
104354
+ const previousUnit = yield* service.readUnit;
104355
+ const paths = yield* deriveServerPaths(status.baseDir, void 0);
104356
+ const runningState = yield* readPersistedServerRuntimeState(paths.serverRuntimeStatePath);
104357
+ const recordedState = yield* readServiceUpdateState(status.baseDir);
104358
+ const previousVersion = Option.flatMap(runningState, (state) => Option.fromNullishOr(state.cliVersion)).pipe(Option.orElse(() => Option.map(recordedState, (state) => state.activeVersion)), Option.getOrElse(() => "unknown"));
104359
+ const startedAfter = yield* DateTime.now;
104360
+ yield* writeServiceUpdateState({
104361
+ baseDir: status.baseDir,
104362
+ status: "updating",
104363
+ activeVersion: previousVersion,
104364
+ pendingVersion: input.cliVersion
104365
+ });
104366
+ const result = yield* reconcileService();
104367
+ if (!result.changed) yield* service.restart;
104368
+ const outcome = yield* awaitServiceStart({
104369
+ serverRuntimeStatePath: paths.serverRuntimeStatePath,
104370
+ dbPath: paths.dbPath,
104371
+ expectedVersion: input.cliVersion,
104372
+ startedAfter,
104373
+ ...input.timeout === void 0 ? {} : { timeout: input.timeout }
104374
+ });
104375
+ if (outcome._tag === "started") {
104376
+ yield* writeServiceUpdateState({
104377
+ baseDir: status.baseDir,
104378
+ status: "idle",
104379
+ activeVersion: input.cliVersion
104380
+ });
104381
+ return {
104382
+ _tag: "started",
104383
+ result
104384
+ };
104385
+ }
104386
+ 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";
104387
+ const installedUnit = yield* service.readUnit;
104388
+ yield* service.revertUnit(previousUnit);
104389
+ const rolledBack = Option.isSome(previousUnit) && Option.getOrElse(installedUnit, () => "") !== previousUnit.value;
104390
+ yield* writeServiceUpdateState({
104391
+ baseDir: status.baseDir,
104392
+ status: rolledBack ? "rolled-back" : "failed",
104393
+ activeVersion: rolledBack ? previousVersion : input.cliVersion,
104394
+ pendingVersion: input.cliVersion,
104395
+ detail
104396
+ });
104397
+ return {
104398
+ _tag: rolledBack ? "rolled-back" : "failed",
104399
+ detail,
104400
+ previousVersion
104401
+ };
104402
+ });
103789
104403
  /** Install, update, or repair the service using the CLI version running this command. */
103790
104404
  const reconcileService = Effect.fn("cli.service.reconcile")(function* () {
103791
104405
  const service = yield* BootService;
@@ -103815,7 +104429,7 @@ function formatReconcileSuccess(input) {
103815
104429
  ...input.platform === "darwin" && !input.previouslyInstalled ? [LAUNCH_AGENT_LOGIN_NOTE] : []
103816
104430
  ].join("\n");
103817
104431
  }
103818
- function formatServiceStatus(status, cliVersion) {
104432
+ function formatServiceStatus(status, cliVersion, lastUpdate) {
103819
104433
  if (!status.supported) return "P4Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
103820
104434
  if (!status.installed) return "P4Code service\n Status: not installed\n Next: Run `p4c service install`.";
103821
104435
  return [
@@ -103824,6 +104438,7 @@ function formatServiceStatus(status, cliVersion) {
103824
104438
  ` Unit: ${status.unitPath}`,
103825
104439
  ` Data: ${status.baseDir}`,
103826
104440
  ` Logs: ${status.logPath}`,
104441
+ ...lastUpdate ? [` Last update: ${lastUpdate}`] : [],
103827
104442
  ...status.current ? [] : [" Next: Run `p4c service update`."]
103828
104443
  ].join("\n");
103829
104444
  }
@@ -103889,15 +104504,24 @@ const serviceInstallCommand = Command.make("install", {
103889
104504
  tailscaleServePort: flags.tailscaleServePort
103890
104505
  }))));
103891
104506
  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* () {
103892
- const result = yield* reconcileService();
103893
- if (!result.changed) {
103894
- yield* (yield* BootService).restart;
104507
+ const outcome = yield* updateServiceGuarded({ cliVersion: version });
104508
+ if (outcome._tag !== "started") {
104509
+ const service = yield* BootService;
104510
+ yield* Console.error(`Logs: ${service.logPath}`);
104511
+ return yield* new ServiceUpdateNotHealthyError({
104512
+ attemptedVersion: version,
104513
+ previousVersion: outcome.previousVersion,
104514
+ rolledBack: outcome._tag === "rolled-back",
104515
+ detail: outcome.detail
104516
+ });
104517
+ }
104518
+ if (!outcome.result.changed) {
103895
104519
  yield* Console.log(`Restarted the P4Code service on @p4code/cli@${version}.`);
103896
104520
  return;
103897
104521
  }
103898
104522
  yield* Console.log(formatReconcileSuccess({
103899
- previouslyInstalled: result.previouslyInstalled,
103900
- plan: result.plan,
104523
+ previouslyInstalled: outcome.result.previouslyInstalled,
104524
+ plan: outcome.result.plan,
103901
104525
  cliVersion: version,
103902
104526
  platform: yield* HostProcessPlatform
103903
104527
  }));
@@ -103958,8 +104582,12 @@ const serviceSelfUpdateCommand = Command.make("self-update", {
103958
104582
  yield* Console.log(`Service now runs @p4code/cli@${installed}.`);
103959
104583
  }))));
103960
104584
  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* () {
103961
- const service = yield* BootService;
103962
- yield* Console.log(formatServiceStatus(yield* service.status, version));
104585
+ const status = yield* (yield* BootService).status;
104586
+ const recorded = yield* readServiceUpdateState(status.baseDir);
104587
+ yield* Console.log(formatServiceStatus(status, version, Option.match(recorded, {
104588
+ onNone: () => null,
104589
+ onSome: describeServiceUpdateState
104590
+ })));
103963
104591
  }))));
103964
104592
  Effect.gen(function* () {
103965
104593
  const { supported, installed, current } = yield* (yield* BootService).status;