@runuai/host 0.9.114 → 0.9.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +2 -0
- package/lib/env.ts +4 -0
- package/lib/host-env.ts +106 -1
- package/lib/standard-image.ts +41 -8
- package/lib/user-credentials-store.ts +104 -0
- package/package.json +1 -1
- package/src/index.ts +4 -1
- package/src/main.ts +28 -1
|
@@ -148,6 +148,13 @@
|
|
|
148
148
|
"when": 1788900000002,
|
|
149
149
|
"tag": "0020_user_credential_store_ref",
|
|
150
150
|
"breakpoints": true
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"idx": 21,
|
|
154
|
+
"version": "6",
|
|
155
|
+
"when": 1788900000003,
|
|
156
|
+
"tag": "0021_project_env_store_ref",
|
|
157
|
+
"breakpoints": true
|
|
151
158
|
}
|
|
152
159
|
]
|
|
153
160
|
}
|
package/db/schema.ts
CHANGED
|
@@ -183,6 +183,8 @@ export const hostProjectEnv = sqliteTable(
|
|
|
183
183
|
updatedAt: integer("updated_at", { mode: "number" })
|
|
184
184
|
.notNull()
|
|
185
185
|
.default(sql`(unixepoch() * 1000)`),
|
|
186
|
+
// ADR-134 slice 3: the credential-store parameter this row mirrors to.
|
|
187
|
+
storeRef: text("store_ref"),
|
|
186
188
|
},
|
|
187
189
|
(t) => ({
|
|
188
190
|
pk: primaryKey({ columns: [t.projectId, t.key] }),
|
package/lib/env.ts
CHANGED
|
@@ -57,6 +57,10 @@ const schema = z.object({
|
|
|
57
57
|
// ADR-135: the broker Lambda a Uai-operated host invokes for a reactive
|
|
58
58
|
// refresh (name or ARN). Unset = scheduled refreshes only.
|
|
59
59
|
UAI_TOKEN_BROKER_FUNCTION: z.string().min(1).optional(),
|
|
60
|
+
// Who keeps the standard image and agent CLIs current. `host` (owned hosts):
|
|
61
|
+
// this process, hourly. `external` (Uai-operated hosts): a cloud job keeps
|
|
62
|
+
// the payload AMI fresh and this host only reconciles once at start.
|
|
63
|
+
UAI_IMAGE_MAINTENANCE: z.enum(["host", "external"]).default("host"),
|
|
60
64
|
NODE_ENV: z
|
|
61
65
|
.enum(["development", "production", "test"])
|
|
62
66
|
.default("development"),
|
package/lib/host-env.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
import { and, eq } from "drizzle-orm";
|
|
18
18
|
|
|
19
|
+
import {
|
|
20
|
+
credentialStorePrefix,
|
|
21
|
+
isParameterPathSegment,
|
|
22
|
+
parameterName,
|
|
23
|
+
queueStoreWrite,
|
|
24
|
+
} from "./credential-store";
|
|
19
25
|
import { getDb, schema } from "./db";
|
|
20
26
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
21
27
|
|
|
@@ -48,11 +54,12 @@ export function setProjectEnvVar(
|
|
|
48
54
|
set: { valueEnc: packValue(value), updatedAt: Date.now() },
|
|
49
55
|
})
|
|
50
56
|
.run();
|
|
57
|
+
mirrorProjectEnvToStore(projectId, key);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/** Remove a project env var (idempotent). */
|
|
54
61
|
export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
55
|
-
getDb()
|
|
62
|
+
const result = getDb()
|
|
56
63
|
.delete(schema.hostProjectEnv)
|
|
57
64
|
.where(
|
|
58
65
|
and(
|
|
@@ -61,6 +68,104 @@ export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
|
61
68
|
),
|
|
62
69
|
)
|
|
63
70
|
.run();
|
|
71
|
+
if (result.changes > 0) {
|
|
72
|
+
const name = projectEnvParameterName(projectId, key);
|
|
73
|
+
if (name) queueStoreWrite(`delete project env ${projectId}/${key}`, (store) => store.delete(name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Credential store mirror (ADR-134 slice 3): `<prefix>/projects/<projectId>/env/<KEY>`.
|
|
79
|
+
// The set/delete frames carry no org, and a project id is org-bound in the
|
|
80
|
+
// cloud, so the project is the owner segment. Owned hosts: no-ops.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface StoredProjectEnv {
|
|
84
|
+
v: 1;
|
|
85
|
+
kind: "project-env";
|
|
86
|
+
projectId: string;
|
|
87
|
+
key: string;
|
|
88
|
+
value: string;
|
|
89
|
+
updatedAt: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Null when the project id or key cannot be a parameter path segment (a
|
|
93
|
+
* key with a character SSM refuses is kept local and logged once). */
|
|
94
|
+
export function projectEnvParameterName(projectId: string, key: string): string | null {
|
|
95
|
+
if (!isParameterPathSegment(projectId) || !isParameterPathSegment(key)) return null;
|
|
96
|
+
return parameterName(credentialStorePrefix(), "projects", projectId, "env", key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The row as it is mirrored (value decrypted). Never logged. */
|
|
100
|
+
export function projectEnvStoreValue(projectId: string, key: string): StoredProjectEnv | null {
|
|
101
|
+
const row = getDb()
|
|
102
|
+
.select()
|
|
103
|
+
.from(schema.hostProjectEnv)
|
|
104
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
105
|
+
.get();
|
|
106
|
+
if (!row) return null;
|
|
107
|
+
return {
|
|
108
|
+
v: 1,
|
|
109
|
+
kind: "project-env",
|
|
110
|
+
projectId,
|
|
111
|
+
key,
|
|
112
|
+
value: unpackValue(row.valueEnc),
|
|
113
|
+
updatedAt: row.updatedAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function setProjectEnvStoreRef(projectId: string, key: string, storeRef: string): void {
|
|
118
|
+
getDb()
|
|
119
|
+
.update(schema.hostProjectEnv)
|
|
120
|
+
.set({ storeRef })
|
|
121
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
122
|
+
.run();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function listProjectEnvStoreRows(): Array<{ projectId: string; key: string; storeRef: string | null }> {
|
|
126
|
+
return getDb()
|
|
127
|
+
.select({
|
|
128
|
+
projectId: schema.hostProjectEnv.projectId,
|
|
129
|
+
key: schema.hostProjectEnv.key,
|
|
130
|
+
storeRef: schema.hostProjectEnv.storeRef,
|
|
131
|
+
})
|
|
132
|
+
.from(schema.hostProjectEnv)
|
|
133
|
+
.all();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mirrorProjectEnvToStore(projectId: string, key: string): void {
|
|
137
|
+
const name = projectEnvParameterName(projectId, key);
|
|
138
|
+
if (!name) return;
|
|
139
|
+
queueStoreWrite(`mirror project env ${projectId}/${key}`, async (store) => {
|
|
140
|
+
const value = projectEnvStoreValue(projectId, key);
|
|
141
|
+
if (!value) return;
|
|
142
|
+
await store.put(name, JSON.stringify(value));
|
|
143
|
+
setProjectEnvStoreRef(projectId, key, name);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reconcile import: a value this host lacks. Existing rows win. */
|
|
148
|
+
export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: string): boolean {
|
|
149
|
+
const result = getDb()
|
|
150
|
+
.insert(schema.hostProjectEnv)
|
|
151
|
+
.values({
|
|
152
|
+
projectId: value.projectId,
|
|
153
|
+
key: value.key,
|
|
154
|
+
valueEnc: packValue(value.value),
|
|
155
|
+
updatedAt: value.updatedAt,
|
|
156
|
+
storeRef,
|
|
157
|
+
})
|
|
158
|
+
.onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
|
|
159
|
+
.run();
|
|
160
|
+
return result.changes > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reconcile: the row's parameter vanished elsewhere; drop the row only. */
|
|
164
|
+
export function deleteProjectEnvLocal(projectId: string, key: string): void {
|
|
165
|
+
getDb()
|
|
166
|
+
.delete(schema.hostProjectEnv)
|
|
167
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
168
|
+
.run();
|
|
64
169
|
}
|
|
65
170
|
|
|
66
171
|
/** Sorted list of a project's env var KEY NAMES (never the values). */
|
package/lib/standard-image.ts
CHANGED
|
@@ -1128,21 +1128,54 @@ function markAgentClisReady(): void {
|
|
|
1128
1128
|
* `{ ok, error? }` so the caller decides (boot: fire-and-forget; taskUp:
|
|
1129
1129
|
* surface the reason). Resolves `agentClisReady` on every exit path.
|
|
1130
1130
|
*/
|
|
1131
|
-
export function ensureStandardImage(
|
|
1131
|
+
export function ensureStandardImage(
|
|
1132
|
+
opts: { maxAgeMs?: number } = {},
|
|
1133
|
+
): Promise<StandardImageResult> {
|
|
1134
|
+
// A task-up passes maxAgeMs: a pass that succeeded recently for the same
|
|
1135
|
+
// engine configuration is reused instead of spending ~15 s spinning a
|
|
1136
|
+
// maintenance container per task (live 2026-09-09: 16 s of every task
|
|
1137
|
+
// start). The hourly schedule (main.ts) and the boot pass keep it fresh.
|
|
1138
|
+
const engines = configuredOptionalEngines();
|
|
1139
|
+
const key = optionalEngineConfigurationKey(engines);
|
|
1140
|
+
const recent = lastOkByKey.get(key);
|
|
1141
|
+
if (
|
|
1142
|
+
opts.maxAgeMs !== undefined &&
|
|
1143
|
+
recent !== undefined &&
|
|
1144
|
+
Date.now() - recent.at <= opts.maxAgeMs
|
|
1145
|
+
) {
|
|
1146
|
+
markAgentClisReady();
|
|
1147
|
+
return Promise.resolve(recent.result);
|
|
1148
|
+
}
|
|
1132
1149
|
// Startup, runtime recovery, and a newly-arrived task can all discover the
|
|
1133
1150
|
// same daemon transition. One shared volume repair/build must own that
|
|
1134
1151
|
// window; concurrent maintenance containers can otherwise replace the same
|
|
1135
1152
|
// CLI shims underneath each other and defeat agentClisReady's fence.
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1153
|
+
return ensureQueue.run(key, () =>
|
|
1154
|
+
ensureStandardImageInner(engines)
|
|
1155
|
+
.then((result) => {
|
|
1156
|
+
if (result.ok) lastOkByKey.set(key, { at: Date.now(), result });
|
|
1157
|
+
else lastOkByKey.delete(key);
|
|
1158
|
+
return result;
|
|
1159
|
+
})
|
|
1160
|
+
.finally(() => {
|
|
1161
|
+
// Always signal — even a docker-down bail — so agent spawns never hang
|
|
1162
|
+
// waiting on a reconcile that will not happen this boot.
|
|
1163
|
+
markAgentClisReady();
|
|
1164
|
+
}),
|
|
1143
1165
|
);
|
|
1144
1166
|
}
|
|
1145
1167
|
|
|
1168
|
+
/** Last successful pass per engine configuration (in-process). */
|
|
1169
|
+
const lastOkByKey = new Map<string, { at: number; result: StandardImageResult }>();
|
|
1170
|
+
|
|
1171
|
+
/** Tests: forget every remembered pass. */
|
|
1172
|
+
export function resetStandardImageMemo(): void {
|
|
1173
|
+
lastOkByKey.clear();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** The owned-host schedule (docs/agent-notes.md, "image maintenance"). */
|
|
1177
|
+
export const MAINTENANCE_INTERVAL_MS = 60 * 60_000;
|
|
1178
|
+
|
|
1146
1179
|
async function ensureStandardImageInner(
|
|
1147
1180
|
engines: OptionalEngineConfiguration,
|
|
1148
1181
|
): Promise<StandardImageResult> {
|
|
@@ -41,6 +41,15 @@ import {
|
|
|
41
41
|
setMcpConnectionStoreRef,
|
|
42
42
|
type StoredMcpConnection,
|
|
43
43
|
} from "./mcp-connections";
|
|
44
|
+
import {
|
|
45
|
+
deleteProjectEnvLocal,
|
|
46
|
+
importProjectEnvFromStore,
|
|
47
|
+
listProjectEnvStoreRows,
|
|
48
|
+
projectEnvParameterName,
|
|
49
|
+
projectEnvStoreValue,
|
|
50
|
+
setProjectEnvStoreRef,
|
|
51
|
+
type StoredProjectEnv,
|
|
52
|
+
} from "./host-env";
|
|
44
53
|
import {
|
|
45
54
|
deleteKey as deleteSshKeyLocal,
|
|
46
55
|
importSshKeyFromStore,
|
|
@@ -308,3 +317,98 @@ export async function reconcileUserCredentialsFromStore(): Promise<ReconcileUser
|
|
|
308
317
|
}
|
|
309
318
|
return result;
|
|
310
319
|
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Slice 3: project env values, `<prefix>/projects/<projectId>/env/<KEY>`.
|
|
323
|
+
// Same three moves as above, own listing.
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
export function parseProjectEnvParameterName(
|
|
327
|
+
name: string,
|
|
328
|
+
prefix: string = credentialStorePrefix(),
|
|
329
|
+
): { projectId: string; key: string } | null {
|
|
330
|
+
if (!name.startsWith(`${prefix}/projects/`)) return null;
|
|
331
|
+
const parts = name.slice(prefix.length + 1).split("/");
|
|
332
|
+
if (parts.length !== 4 || parts[2] !== "env") return null;
|
|
333
|
+
if (!parts.every((p) => isParameterPathSegment(p))) return null;
|
|
334
|
+
const [, projectId, , key] = parts as [string, string, string, string];
|
|
335
|
+
return { projectId, key };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function parseStoredProjectEnv(value: string): StoredProjectEnv {
|
|
339
|
+
const o = obj(value);
|
|
340
|
+
if (o.kind !== "project-env") throw new Error("not a project env value");
|
|
341
|
+
return {
|
|
342
|
+
v: 1,
|
|
343
|
+
kind: "project-env",
|
|
344
|
+
projectId: str(o, "projectId"),
|
|
345
|
+
key: str(o, "key"),
|
|
346
|
+
value: str(o, "value"),
|
|
347
|
+
updatedAt: num(o, "updatedAt"),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCredentialsResult> {
|
|
352
|
+
const result: ReconcileUserCredentialsResult = {
|
|
353
|
+
skipped: false,
|
|
354
|
+
imported: 0,
|
|
355
|
+
uploaded: 0,
|
|
356
|
+
removed: 0,
|
|
357
|
+
errors: 0,
|
|
358
|
+
};
|
|
359
|
+
const store = configuredCredentialStore();
|
|
360
|
+
if (!store) return { ...result, skipped: true };
|
|
361
|
+
const prefix = credentialStorePrefix();
|
|
362
|
+
const path = `${prefix}/projects`;
|
|
363
|
+
const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
|
|
364
|
+
let listed: Array<{ name: string; value: string }>;
|
|
365
|
+
try {
|
|
366
|
+
listed = await store.list(path);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
result.errors++;
|
|
369
|
+
warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
|
|
373
|
+
const seen = new Set<string>();
|
|
374
|
+
for (const param of listed) {
|
|
375
|
+
const at = parseProjectEnvParameterName(param.name, prefix);
|
|
376
|
+
if (!at) continue;
|
|
377
|
+
seen.add(param.name);
|
|
378
|
+
try {
|
|
379
|
+
const value = parseStoredProjectEnv(param.value);
|
|
380
|
+
if (value.projectId !== at.projectId || value.key !== at.key) {
|
|
381
|
+
throw new Error("payload does not describe the value its path names");
|
|
382
|
+
}
|
|
383
|
+
const row = local.get(`${at.projectId}/${at.key}`);
|
|
384
|
+
if (row) {
|
|
385
|
+
if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
|
|
386
|
+
} else if (importProjectEnvFromStore(value, param.name)) {
|
|
387
|
+
result.imported++;
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
result.errors++;
|
|
391
|
+
warn(`${param.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const row of local.values()) {
|
|
395
|
+
try {
|
|
396
|
+
if (row.storeRef) {
|
|
397
|
+
if (row.storeRef.startsWith(`${path}/`) && !seen.has(row.storeRef)) {
|
|
398
|
+
deleteProjectEnvLocal(row.projectId, row.key);
|
|
399
|
+
result.removed++;
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const name = projectEnvParameterName(row.projectId, row.key);
|
|
404
|
+
const value = projectEnvStoreValue(row.projectId, row.key);
|
|
405
|
+
if (!name || !value) continue;
|
|
406
|
+
await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
|
|
407
|
+
result.uploaded++;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
result.errors++;
|
|
410
|
+
warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -792,7 +792,10 @@ export const hostCommands: HostCommands = {
|
|
|
792
792
|
// a real build only when missing/stale (Docker is definitely up now, a
|
|
793
793
|
// task just arrived). On a build FAILURE, surface WHY to the cloud (not
|
|
794
794
|
// just the host log — the operator can't read a remote user's machine).
|
|
795
|
-
|
|
795
|
+
// A pass that succeeded within the hour is reused (2026-09-09: the
|
|
796
|
+
// re-check cost 16 s of every task start); the hourly schedule in
|
|
797
|
+
// main.ts and the boot pass keep it fresh.
|
|
798
|
+
const img = await ensureStandardImage({ maxAgeMs: 60 * 60_000 });
|
|
796
799
|
if (!img.ok) {
|
|
797
800
|
throw new AgentError(
|
|
798
801
|
"STANDARD_IMAGE_UNAVAILABLE",
|
package/src/main.ts
CHANGED
|
@@ -163,7 +163,10 @@ import {
|
|
|
163
163
|
listEngineAccountsAllScopes,
|
|
164
164
|
reconcileEngineAccountsFromStore,
|
|
165
165
|
} from "../lib/engine-accounts";
|
|
166
|
-
import {
|
|
166
|
+
import {
|
|
167
|
+
reconcileProjectEnvFromStore,
|
|
168
|
+
reconcileUserCredentialsFromStore,
|
|
169
|
+
} from "../lib/user-credentials-store";
|
|
167
170
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
168
171
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
169
172
|
import {
|
|
@@ -176,6 +179,7 @@ import {
|
|
|
176
179
|
import "../lib/agents/factory";
|
|
177
180
|
import {
|
|
178
181
|
areAgentClisReady,
|
|
182
|
+
MAINTENANCE_INTERVAL_MS,
|
|
179
183
|
configuredOptionalEngines,
|
|
180
184
|
ensureStandardImage,
|
|
181
185
|
onAgentClisReady,
|
|
@@ -465,6 +469,17 @@ void reconcileEngineAccountsFromStore({ hostId })
|
|
|
465
469
|
`[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
466
470
|
);
|
|
467
471
|
});
|
|
472
|
+
// Image + agent-CLI maintenance runs where the compute is owned: an owned
|
|
473
|
+
// host keeps its own image current on an hourly schedule (a task-up reuses
|
|
474
|
+
// the last pass instead of blocking on one); a Uai-operated host sets
|
|
475
|
+
// UAI_IMAGE_MAINTENANCE=external — a cloud job keeps the payload AMI fresh —
|
|
476
|
+
// and reconciles only once, at start.
|
|
477
|
+
if (env.UAI_IMAGE_MAINTENANCE === "host") {
|
|
478
|
+
const maintenanceTimer = setInterval(() => {
|
|
479
|
+
void ensureStandardImage().catch(() => undefined);
|
|
480
|
+
}, MAINTENANCE_INTERVAL_MS);
|
|
481
|
+
maintenanceTimer.unref?.();
|
|
482
|
+
}
|
|
468
483
|
// ADR-135: one refresher per rotating credential. Owned host → the in-process
|
|
469
484
|
// loop refreshes and re-pushes; Uai-operated host → the broker Lambda
|
|
470
485
|
// refreshes and this host picks up changed parameter versions.
|
|
@@ -488,6 +503,18 @@ void reconcileUserCredentialsFromStore()
|
|
|
488
503
|
`[user-credentials] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
489
504
|
);
|
|
490
505
|
});
|
|
506
|
+
void reconcileProjectEnvFromStore()
|
|
507
|
+
.then((r) => {
|
|
508
|
+
if (r.skipped) return;
|
|
509
|
+
console.log(
|
|
510
|
+
`[project-env] credential store reconciled: ${r.imported} imported, ${r.uploaded} uploaded, ${r.removed} removed, ${r.errors} errors`,
|
|
511
|
+
);
|
|
512
|
+
})
|
|
513
|
+
.catch((error: unknown) => {
|
|
514
|
+
console.warn(
|
|
515
|
+
`[project-env] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
491
518
|
// Reconcile a durable retention-off setting before recovery/connect can make
|
|
492
519
|
// crash-left owner-facing terminal history observable again. Correctness
|
|
493
520
|
// tombstones remain intact inside applyTerminalHistoryRetention.
|