forgeos 0.1.0-alpha.24 → 0.1.0-alpha.26

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/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.24 input=1257771517e723f30d9c8c6bdc328f3a9d0bfde1e673d859837f93f0f1bd15c4 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
1
+ // @forge-generated generator=0.1.0-alpha.26 input=23c00c5407aab4088b55b49905f2b195667a75b8544fa180786ac637ee67e1e5 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,7 +1,35 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.26
4
+
5
+ ### Patch Changes
6
+
7
+ - Harden the field-demo loop after the Team Onboarding app exercise.
8
+
9
+ - Let `forge changed` and `forge handoff` summarize non-git workspaces with a filesystem inventory instead of reporting zero useful changes.
10
+ - Keep `forge make resource` global by default unless a tenants table exists or `--tenant-scoped` is explicit.
11
+ - Expand capability-map table detection for aliased `ctx.db` usage.
12
+ - Wait through short-lived DeltaDB writer locks before reporting `FORGE_DELTA_BUSY`.
13
+
3
14
  ## Unreleased
4
15
 
16
+ ## 0.1.0-alpha.25
17
+
18
+ ### Patch Changes
19
+
20
+ - Harden DeltaDB and Agent Memory under real `forge dev` concurrency.
21
+
22
+ - Stop long-running dev recorders from holding the DeltaDB writer lock between events.
23
+ - Retry short transient DeltaDB writer conflicts before reporting `FORGE_DELTA_BUSY`.
24
+ - Keep Codex hook queue checkpoints unchanged when Agent Memory ingest is blocked by a busy DeltaDB writer, then retry safely instead of losing queued events.
25
+ - Add watcher backoff metadata for lock recovery and document the safe queue/DeltaDB behavior.
26
+
27
+ - Fix tenant-scope reporting in the generated agent contract and capability map.
28
+
29
+ - Match tenant-scoped tables by both authored/camelCase table names and generated SQL snake_case table names.
30
+ - Report camelCase liveQuery dependencies such as `onboardingTasks` as `tenant` scoped when `tenantScope.json` confirms `tenant_id`.
31
+ - Add regression coverage for the Team Onboarding style liveQuery/capability-map path.
32
+
5
33
  ## 0.1.0-alpha.24
6
34
 
7
35
  ### Patch Changes
package/docs/changelog.md CHANGED
@@ -6,6 +6,28 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.26
10
+
11
+ - Hardened the field-demo loop after the Team Onboarding app exercise:
12
+ non-git workspaces now get a filesystem-backed `changed`/`handoff` summary,
13
+ `forge make resource` stays global unless tenant scope is explicit or already
14
+ modeled, capability-map extraction sees aliased `ctx.db` table usage, and
15
+ Agent Memory waits through short-lived DeltaDB writer locks before reporting
16
+ `FORGE_DELTA_BUSY`.
17
+
18
+ ## 0.1.0-alpha.25
19
+
20
+ - Hardened DeltaDB and Agent Memory for concurrent `forge dev` usage:
21
+ dev recorders now release the writer lock between events, Agent Memory ingest
22
+ retries short transient writer conflicts, and queued Codex hook events keep
23
+ their checkpoint unchanged when DeltaDB is temporarily busy.
24
+ - Fixed tenant-scope reporting in the generated agent contract and capability
25
+ map for camelCase authored tables such as `onboardingTasks`, so liveQuery
26
+ dependencies now report `tenant` scope when `tenantScope.json` confirms the
27
+ table is tenant-scoped.
28
+ - Added regression tests and docs for the DeltaDB lock recovery path and the
29
+ Team Onboarding style capability-map tenant-scope path.
30
+
9
31
  ## 0.1.0-alpha.24
10
32
 
11
33
  - Consolidated the public alpha adoption surface: MIT license, package license
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.24",
3
+ "version": "0.1.0-alpha.26",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.24","releaseId":"forgeos@0.1.0-alpha.24+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.26","releaseId":"forgeos@0.1.0-alpha.26+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.24 input=1257771517e723f30d9c8c6bdc328f3a9d0bfde1e673d859837f93f0f1bd15c4 content=4170e33ff26cbaa55732ed4f507f83f2ece3812a1c2184614337c8be91110499
1
+ // @forge-generated generator=0.1.0-alpha.26 input=23c00c5407aab4088b55b49905f2b195667a75b8544fa180786ac637ee67e1e5 content=967d4e05aa540b3e3d843fa6ed4516658ab4c364c1d8c42a14f36c57b75dff71
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.24",
23
- "releaseId": "forgeos@0.1.0-alpha.24+unknown",
22
+ "packageVersion": "0.1.0-alpha.26",
23
+ "releaseId": "forgeos@0.1.0-alpha.26+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -113,10 +113,19 @@ async function openMemoryStore(
113
113
  workspaceRoot: string,
114
114
  access: "read" | "write" = "write",
115
115
  ): Promise<DeltaStore | AgentMemoryUnavailableResult> {
116
- try {
117
- return await DeltaStore.open(workspaceRoot, { access });
118
- } catch (error) {
119
- return memoryUnavailable(error, workspaceRoot);
116
+ const retryDelays = access === "write" ? [25, 75, 150] : [];
117
+ for (let attempt = 0; ; attempt += 1) {
118
+ try {
119
+ return await DeltaStore.open(workspaceRoot, {
120
+ access,
121
+ ...(access === "write" ? { waitMs: 1_500, retryDelayMs: 50 } : {}),
122
+ });
123
+ } catch (error) {
124
+ if (!(error instanceof DeltaStoreBusyError) || attempt >= retryDelays.length) {
125
+ return memoryUnavailable(error, workspaceRoot);
126
+ }
127
+ await sleep(retryDelays[attempt] ?? 0);
128
+ }
120
129
  }
121
130
  }
122
131
 
@@ -131,6 +140,10 @@ function isExternalPgliteRead(result: AgentMemoryUnavailableResult): boolean {
131
140
  );
132
141
  }
133
142
 
143
+ function isDeltaBusyIngestResult(result: AgentIngestResult): boolean {
144
+ return result.ok === false && result.busy?.code === "FORGE_DELTA_BUSY";
145
+ }
146
+
134
147
  function fallbackMemoryPath(workspaceRoot: string): string {
135
148
  return join(workspaceRoot, ".forge", "agent", "events.ndjson");
136
149
  }
@@ -413,23 +426,29 @@ async function ingestAgentMemoryQueueFile(options: AgentMemoryCommandOptions): P
413
426
  source,
414
427
  eventName: options.eventName,
415
428
  });
429
+ const errors = [
430
+ ...drained.errors,
431
+ ...(drained.busy ? ["DeltaDB is busy; queue checkpoint was not advanced"] : []),
432
+ ];
416
433
  return {
417
- ok: drained.errors.length === 0,
434
+ ok: errors.length === 0,
418
435
  watch: false,
419
436
  source,
420
437
  file: options.file,
421
438
  eventsIngested: drained.eventsIngested,
422
- errors: drained.errors,
439
+ errors,
423
440
  bytesRead: drained.bytesRead,
424
441
  pendingBytes: drained.pendingBytes,
425
442
  checkpointFile: drained.checkpointFile,
426
443
  compacted: drained.compacted,
427
444
  historyFile: drained.historyFile,
445
+ ...(drained.busy ? { busy: drained.busy, pendingDueToBusy: true } : {}),
428
446
  nextActions: [
447
+ ...(drained.busy ? ["forge delta status --json"] : []),
429
448
  `forge agent memory --entry ${source} --json`,
430
449
  `forge agent hooks status --target ${source} --json`,
431
450
  ],
432
- exitCode: drained.errors.length === 0 ? 0 : 1,
451
+ exitCode: errors.length === 0 ? 0 : 1,
433
452
  };
434
453
  }
435
454
 
@@ -644,6 +663,7 @@ export async function drainAgentMemoryQueueFile(options: {
644
663
  checkpointFile: string;
645
664
  compacted: boolean;
646
665
  historyFile: string;
666
+ busy?: AgentMemoryUnavailableResult["busy"];
647
667
  }> {
648
668
  const historyFile = queueHistoryPath(options.watchFile);
649
669
  if (!existsSync(options.watchFile)) {
@@ -699,6 +719,17 @@ export async function drainAgentMemoryQueueFile(options: {
699
719
  eventsIngested += 1;
700
720
  consumedOffset = bytesRead + line.endOffset;
701
721
  writeQueueCheckpoint(options.watchFile, consumedOffset);
722
+ } else if (isDeltaBusyIngestResult(result)) {
723
+ return {
724
+ eventsIngested,
725
+ errors,
726
+ bytesRead,
727
+ pendingBytes,
728
+ checkpointFile: queueCheckpointPath(options.watchFile),
729
+ compacted: false,
730
+ historyFile,
731
+ busy: result.busy,
732
+ };
702
733
  } else {
703
734
  errors.push(result.error ?? "agent memory ingest failed");
704
735
  break;
@@ -886,7 +917,19 @@ async function watchAgentMemoryIngest(options: AgentMemoryCommandOptions): Promi
886
917
 
887
918
  let eventsIngested = 0;
888
919
  const errors: string[] = [];
920
+ let busyRetries = 0;
921
+ let lastBusy: AgentMemoryUnavailableResult["busy"] | undefined;
889
922
  let pendingIngest = Promise.resolve();
923
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
924
+ const scheduleBusyRetry = () => {
925
+ if (retryTimer) {
926
+ return;
927
+ }
928
+ retryTimer = setTimeout(() => {
929
+ retryTimer = undefined;
930
+ pendingIngest = pendingIngest.then(ingestNewContent, ingestNewContent);
931
+ }, 500);
932
+ };
890
933
  const ingestNewContent = async () => {
891
934
  const result = await drainAgentMemoryQueueFile({
892
935
  workspaceRoot: options.workspaceRoot,
@@ -895,6 +938,13 @@ async function watchAgentMemoryIngest(options: AgentMemoryCommandOptions): Promi
895
938
  eventName: options.eventName,
896
939
  });
897
940
  eventsIngested += result.eventsIngested;
941
+ if (result.busy) {
942
+ busyRetries += 1;
943
+ lastBusy = result.busy;
944
+ scheduleBusyRetry();
945
+ return;
946
+ }
947
+ lastBusy = undefined;
898
948
  errors.push(...result.errors);
899
949
  };
900
950
 
@@ -904,6 +954,10 @@ async function watchAgentMemoryIngest(options: AgentMemoryCommandOptions): Promi
904
954
  pendingIngest = pendingIngest.then(ingestNewContent, ingestNewContent);
905
955
  });
906
956
  const shutdown = () => {
957
+ if (retryTimer) {
958
+ clearTimeout(retryTimer);
959
+ retryTimer = undefined;
960
+ }
907
961
  watcher.close();
908
962
  void pendingIngest.finally(() => {
909
963
  resolve({
@@ -913,7 +967,9 @@ async function watchAgentMemoryIngest(options: AgentMemoryCommandOptions): Promi
913
967
  file,
914
968
  eventsIngested,
915
969
  errors,
970
+ ...(lastBusy ? { busy: lastBusy, pendingDueToBusy: true, busyRetries } : {}),
916
971
  nextActions: [
972
+ ...(lastBusy ? ["forge delta status --json"] : []),
917
973
  `forge agent memory --entry ${source} --json`,
918
974
  `forge agent hooks status --target ${source} --json`,
919
975
  ],
@@ -971,6 +1027,10 @@ function installAgentMemory(options: AgentMemoryCommandOptions): AgentInstallRes
971
1027
  return cursorInstallResult(filesWritten, planned);
972
1028
  }
973
1029
 
1030
+ function sleep(ms: number): Promise<void> {
1031
+ return new Promise((resolve) => setTimeout(resolve, ms));
1032
+ }
1033
+
974
1034
  export function formatAgentMemoryJson(result: AgentMemoryCommandResult): string {
975
1035
  return `${JSON.stringify(result, null, 2)}\n`;
976
1036
  }
@@ -155,6 +155,7 @@ export interface AgentIngestResult {
155
155
  path: string;
156
156
  reason: "pglite-active";
157
157
  };
158
+ busy?: AgentMemoryUnavailableResult["busy"];
158
159
  exitCode: 0 | 1;
159
160
  error?: string;
160
161
  diagnostics?: Diagnostic[];
@@ -174,6 +175,9 @@ export interface AgentIngestWatchResult {
174
175
  checkpointFile?: string;
175
176
  compacted?: boolean;
176
177
  historyFile?: string;
178
+ busy?: AgentMemoryUnavailableResult["busy"];
179
+ pendingDueToBusy?: boolean;
180
+ busyRetries?: number;
177
181
  nextActions: string[];
178
182
  exitCode: 0 | 1;
179
183
  }
@@ -82,8 +82,7 @@ function buildRisks(git: WorkspaceGitSummary): string[] {
82
82
  const risks: string[] = [];
83
83
  const changed = git.changeSummary.changed;
84
84
  if (!git.available) {
85
- risks.push("git status is unavailable; changed-file analysis may be incomplete");
86
- return risks;
85
+ risks.push("git status is unavailable; using filesystem inventory as untracked-file analysis");
87
86
  }
88
87
  if (git.untracked.count > 0) {
89
88
  risks.push(`${git.untracked.count} untracked file(s) are not in git history`);
@@ -103,7 +102,7 @@ function buildRisks(git: WorkspaceGitSummary): string[] {
103
102
 
104
103
  function buildRecommendedCommands(git: WorkspaceGitSummary): string[] {
105
104
  if (!git.available) {
106
- return ["git status --short", "forge status --json"];
105
+ return ["forge status --json", "forge handoff --json", "git init"];
107
106
  }
108
107
  if (git.changeSummary.changed.total.count === 0) {
109
108
  return ["forge status --json", "forge dev --once --json"];
@@ -202,12 +201,13 @@ export function runChangedCommand(workspaceRoot: string, options: { authoredOnly
202
201
  const reviewFocus = buildReviewFocus(humanChanges, viewDerivedChanges);
203
202
  const generatedExplanation = buildGeneratedChangeExplanation(humanChanges, viewDerivedChanges);
204
203
  const diffPlan: DiffPlan = buildDiffPlanFromChangeSummary(viewChanged);
204
+ const ok = git.available || git.source === "filesystem";
205
205
 
206
206
  return {
207
- ok: git.available,
207
+ ok,
208
208
  data: {
209
209
  schemaVersion: "0.1.0",
210
- ok: git.available,
210
+ ok,
211
211
  summary: {
212
212
  branch: git.branch,
213
213
  commit: git.commit,
@@ -223,6 +223,7 @@ export function runChangedCommand(workspaceRoot: string, options: { authoredOnly
223
223
  },
224
224
  git: {
225
225
  available: git.available,
226
+ source: git.source,
226
227
  ...(git.error ? { error: git.error } : {}),
227
228
  branch: git.branch,
228
229
  commit: git.commit,
@@ -240,7 +241,7 @@ export function runChangedCommand(workspaceRoot: string, options: { authoredOnly
240
241
  recommendedCommands,
241
242
  nextActions: recommendedCommands,
242
243
  },
243
- exitCode: git.available ? 0 : 1,
244
+ exitCode: ok ? 0 : 1,
244
245
  };
245
246
  }
246
247
 
@@ -140,6 +140,7 @@ function buildOpeningBrief(input: {
140
140
  }): string {
141
141
  const agent = input.dev.summary.agentContext;
142
142
  const changedByType = summarizeChangeTypes(input.git.changeSummary.changed);
143
+ const changedFiles = Math.max(agent.changedFiles, input.git.changed.count);
143
144
  const tests = input.recentRuns.test
144
145
  ? input.recentRuns.test.ok
145
146
  ? "last test run passed"
@@ -150,7 +151,7 @@ function buildOpeningBrief(input: {
150
151
  : "no blocking issues";
151
152
  return [
152
153
  `ForgeOS handoff: ${input.dev.ok ? "dev diagnostics are clean" : "dev diagnostics need attention"}.`,
153
- `${agent.changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
154
+ `${changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
154
155
  `${tests}; ${blockers}.`,
155
156
  `Next command: ${input.dev.summary.primaryAction?.command ?? input.dev.nextActions[0]?.command ?? "forge dev"}.`,
156
157
  ].join(" ");
@@ -168,6 +169,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
168
169
  const agent = dev.summary.agentContext;
169
170
  const risks = [
170
171
  ...agent.blockingIssues,
172
+ ...(!git.available ? ["git status is unavailable; using filesystem inventory as untracked-file analysis"] : []),
171
173
  ...(git.untracked.count > 0 ? [`${git.untracked.count} untracked file(s) are not in git history`] : []),
172
174
  ...(recentRuns.test && !recentRuns.test.ok ? ["last test run failed"] : []),
173
175
  ...(recentRuns.ui && !recentRuns.ui.ok ? ["last UI run failed"] : []),
@@ -181,6 +183,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
181
183
  agent.blockingIssues.length === 0 &&
182
184
  (!recentRuns.test || recentRuns.test.ok) &&
183
185
  (!recentRuns.ui || recentRuns.ui.ok);
186
+ const changedFiles = Math.max(agent.changedFiles, git.changed.count);
184
187
 
185
188
  return {
186
189
  schemaVersion: "0.1.0",
@@ -192,7 +195,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
192
195
  generatedChanged: agent.generatedChanged,
193
196
  generatedChangedFiles: agent.generatedChangedFiles,
194
197
  frontendReady: agent.frontendReady,
195
- changedFiles: agent.changedFiles,
198
+ changedFiles,
196
199
  stagedFiles: git.staged.count,
197
200
  unstagedFiles: git.unstaged.count,
198
201
  untrackedFiles: git.untracked.count,
@@ -7,6 +7,7 @@ import { detectSecrets } from "../classifier/secrets.ts";
7
7
  import { GENERATOR_VERSION } from "../emitter/constants.ts";
8
8
  import { stripDeterministicHeader } from "../primitives/header.ts";
9
9
  import { canonicalJson, normalizeNewlines, serializeCanonical } from "../primitives/serialize.ts";
10
+ import { toSnakeCase } from "../data-graph/sql/naming.ts";
10
11
  import { resolveByPackageName } from "../recipes/registry.ts";
11
12
  import {
12
13
  defaultRuntimeCompatibility,
@@ -185,6 +186,38 @@ function sourceText(workspaceRoot: string, file: string | undefined): string {
185
186
  return nodeFileSystem.readText(absolute) ?? "";
186
187
  }
187
188
 
189
+ function escapeRegExp(value: string): string {
190
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
191
+ }
192
+
193
+ function addDbAliasesForText(
194
+ text: string,
195
+ tableNames: Set<string>,
196
+ aliases: Map<string, string>,
197
+ ): void {
198
+ for (const match of text.matchAll(
199
+ /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*ctx\.db(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[\s*["'`]([^"'`]+)["'`]\s*\])/g,
200
+ )) {
201
+ const alias = match[1] ?? "";
202
+ const table = match[2] ?? match[3] ?? "";
203
+ if (alias && tableNames.has(table)) {
204
+ aliases.set(alias, table);
205
+ }
206
+ }
207
+
208
+ for (const match of text.matchAll(/\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*ctx\.db/g)) {
209
+ const body = match[1] ?? "";
210
+ for (const part of body.split(",")) {
211
+ const [rawTable, rawAlias] = part.split(":").map((value) => value.trim());
212
+ const table = rawTable?.replace(/["'`]/g, "") ?? "";
213
+ const alias = (rawAlias ?? rawTable ?? "").replace(/\s*=.*$/, "").trim();
214
+ if (tableNames.has(table) && alias) {
215
+ aliases.set(alias, table);
216
+ }
217
+ }
218
+ }
219
+ }
220
+
188
221
  function dbTablesForText(
189
222
  text: string,
190
223
  tableNames: Set<string>,
@@ -198,6 +231,24 @@ function dbTablesForText(
198
231
  tables.push(table);
199
232
  }
200
233
  }
234
+ for (const match of text.matchAll(/ctx\.db\s*\[\s*["'`]([^"'`]+)["'`]\s*\]\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
235
+ const table = match[1] ?? "";
236
+ const op = match[2] ?? "";
237
+ if (tableNames.has(table) && ops.has(op)) {
238
+ tables.push(table);
239
+ }
240
+ }
241
+ const aliases = new Map<string, string>();
242
+ addDbAliasesForText(text, tableNames, aliases);
243
+ for (const [alias, table] of aliases) {
244
+ const aliasPattern = new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*([A-Za-z_$][A-Za-z0-9_$]*)`, "g");
245
+ for (const match of text.matchAll(aliasPattern)) {
246
+ const op = match[1] ?? "";
247
+ if (ops.has(op)) {
248
+ tables.push(table);
249
+ }
250
+ }
251
+ }
201
252
  return uniqueSorted(tables);
202
253
  }
203
254
 
@@ -288,6 +339,35 @@ function runtimeRules(): AgentRuntimeRule[] {
288
339
  ];
289
340
  }
290
341
 
342
+ type TenantTableInfo = {
343
+ tenantIdColumn: string;
344
+ tenantField: string;
345
+ };
346
+
347
+ function buildTenantTableLookup(tenantScope: TenantScope, dataGraph: DataGraph): Map<string, TenantTableInfo> {
348
+ const lookup = new Map<string, TenantTableInfo>();
349
+ for (const scoped of tenantScope.tables) {
350
+ const table = dataGraph.tables.find((candidate) =>
351
+ candidate.name === scoped.table ||
352
+ candidate.exportName === scoped.exportName ||
353
+ toSnakeCase(candidate.name) === scoped.table ||
354
+ toSnakeCase(candidate.exportName) === scoped.table
355
+ );
356
+ const tenantField = table?.fields.find((field) => toSnakeCase(field.name) === scoped.tenantIdColumn)?.name ??
357
+ scoped.tenantIdColumn;
358
+ const info = { tenantIdColumn: scoped.tenantIdColumn, tenantField };
359
+ for (const key of uniqueSorted([
360
+ scoped.table,
361
+ scoped.exportName,
362
+ table?.name,
363
+ table?.exportName,
364
+ ].filter((key): key is string => typeof key === "string" && key.length > 0))) {
365
+ lookup.set(key, info);
366
+ }
367
+ }
368
+ return lookup;
369
+ }
370
+
291
371
  function agentProtocols(workspaceRoot: string): AgentProtocolInfo[] {
292
372
  return [
293
373
  {
@@ -1037,9 +1117,7 @@ export function buildAgentContractArtifacts(
1037
1117
  input: AgentContractInput,
1038
1118
  ): AgentContractArtifacts {
1039
1119
  const project = readPackageInfo(input.workspaceRoot);
1040
- const tenantTables = new Map(
1041
- input.tenantScope.tables.map((table) => [table.table, table.tenantIdColumn]),
1042
- );
1120
+ const tenantTables = buildTenantTableLookup(input.tenantScope, input.dataGraph);
1043
1121
  const commandAuth = new Map(
1044
1122
  input.policyRegistry.commandAuth.map((binding) => [binding.commandName, binding.auth]),
1045
1123
  );
@@ -1183,13 +1261,16 @@ export function buildAgentContractArtifacts(
1183
1261
  }),
1184
1262
  ),
1185
1263
  data: {
1186
- tables: sorted(input.dataGraph.tables, (table) => table.name).map((table) => ({
1187
- name: table.name,
1188
- file: table.file,
1189
- tenantScoped: tenantTables.has(table.name),
1190
- ...(tenantTables.has(table.name) ? { tenantField: tenantTables.get(table.name) } : {}),
1191
- fields: uniqueSorted(table.fields.map((field) => field.name)),
1192
- })),
1264
+ tables: sorted(input.dataGraph.tables, (table) => table.name).map((table) => {
1265
+ const tenantInfo = tenantTables.get(table.name);
1266
+ return {
1267
+ name: table.name,
1268
+ file: table.file,
1269
+ tenantScoped: Boolean(tenantInfo),
1270
+ ...(tenantInfo ? { tenantField: tenantInfo.tenantField } : {}),
1271
+ fields: uniqueSorted(table.fields.map((field) => field.name)),
1272
+ };
1273
+ }),
1193
1274
  },
1194
1275
  policies: sorted(input.policyRegistry.policies, (policy) => policy.name).map((policy) => ({
1195
1276
  name: policy.name,
@@ -4,7 +4,7 @@ import type { ForgeCommand } from "../cli/parse.ts";
4
4
  import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
5
5
  import { normalizePath } from "../compiler/primitives/paths.ts";
6
6
  import { hashUtf8Bytes } from "../compiler/primitives/hash.ts";
7
- import { DeltaStore, type DeltaRuntimeCallInput } from "./store.ts";
7
+ import { DeltaStore, DeltaStoreBusyError, type DeltaRuntimeCallInput } from "./store.ts";
8
8
  import { classifyArtifactKind } from "./classifier.ts";
9
9
  import { readDeltaGitSnapshot } from "./git-observer.ts";
10
10
 
@@ -28,70 +28,129 @@ export async function createAmbientDeltaRecorder(
28
28
  if (isDeltaDisabled()) {
29
29
  return noopRecorder;
30
30
  }
31
- try {
32
- const store = await DeltaStore.open(workspaceRoot);
33
- const actorId = await store.ensureActor("forge", "forge-cli", { pid: process.pid });
34
- const sessionId = await store.createSession({
31
+ let actorId: string | undefined;
32
+ let sessionId: string | undefined;
33
+ let accepting = true;
34
+ let closed = false;
35
+ let queue = Promise.resolve();
36
+
37
+ const withStore = async (fn: (store: DeltaStore) => Promise<void>): Promise<void> => {
38
+ const store = await openDeltaStoreWithRetry(workspaceRoot);
39
+ try {
40
+ await fn(store);
41
+ } finally {
42
+ await store.close();
43
+ }
44
+ };
45
+
46
+ const enqueue = (fn: (store: DeltaStore) => Promise<void>): Promise<void> => {
47
+ queue = queue.then(() => safeDelta(() => withStore(fn)));
48
+ return queue;
49
+ };
50
+
51
+ const ensureSession = async (store: DeltaStore): Promise<{ actorId: string; sessionId: string } | null> => {
52
+ if (actorId && sessionId) {
53
+ return { actorId, sessionId };
54
+ }
55
+ actorId = await store.ensureActor("forge", "forge-cli", { pid: process.pid });
56
+ sessionId = await store.createSession({
35
57
  source,
36
58
  summary,
37
59
  metadata: { actorId },
38
60
  git: readDeltaGitSnapshot(workspaceRoot),
39
61
  });
40
- return {
41
- sessionId,
42
- async recordRuntimeCall(input) {
43
- await safeDelta(async () => {
44
- const failedCode = input.diagnosticCode ?? diagnosticCode(input.diagnostics);
45
- await store.appendOperation({
46
- sessionId,
47
- actorId,
48
- kind: input.result === "denied"
49
- ? "runtime.entry.denied"
50
- : input.result === "failed"
51
- ? "runtime.entry.failed"
52
- : "runtime.entry.executed",
53
- summary: `${input.entryName} ${input.result ?? "executed"}`,
54
- data: {
55
- entryName: input.entryName,
56
- entryKind: input.entryKind,
57
- result: input.result,
58
- traceId: input.traceId,
59
- diagnosticCode: failedCode,
60
- },
61
- runtimeCall: { ...input, diagnosticCode: failedCode },
62
- });
63
- });
64
- },
65
- async recordAgentTool(input) {
66
- await safeDelta(async () => {
67
- await store.appendOperation({
68
- sessionId,
69
- actorId,
70
- kind: "agent.tool.called",
71
- summary: `${input.toolName} ${input.status}`,
72
- data: {
73
- toolName: input.toolName,
74
- risk: input.risk,
75
- status: input.status,
76
- traceId: input.traceId,
77
- durationMs: input.durationMs,
78
- },
79
- });
62
+ return { actorId, sessionId };
63
+ };
64
+
65
+ await enqueue(async (store) => {
66
+ await ensureSession(store);
67
+ });
68
+
69
+ return {
70
+ get sessionId() {
71
+ return sessionId;
72
+ },
73
+ async recordRuntimeCall(input) {
74
+ if (!accepting) {
75
+ return;
76
+ }
77
+ await enqueue(async (store) => {
78
+ const session = await ensureSession(store);
79
+ if (!session) {
80
+ return;
81
+ }
82
+ const failedCode = input.diagnosticCode ?? diagnosticCode(input.diagnostics);
83
+ await store.appendOperation({
84
+ sessionId: session.sessionId,
85
+ actorId: session.actorId,
86
+ kind: input.result === "denied"
87
+ ? "runtime.entry.denied"
88
+ : input.result === "failed"
89
+ ? "runtime.entry.failed"
90
+ : "runtime.entry.executed",
91
+ summary: `${input.entryName} ${input.result ?? "executed"}`,
92
+ data: {
93
+ entryName: input.entryName,
94
+ entryKind: input.entryKind,
95
+ result: input.result,
96
+ traceId: input.traceId,
97
+ diagnosticCode: failedCode,
98
+ },
99
+ runtimeCall: { ...input, diagnosticCode: failedCode },
80
100
  });
81
- },
82
- async recordFileChanged(path, changeType = "modified") {
83
- await safeDelta(() => store.recordFilePath(sessionId, path, changeType));
84
- },
85
- async close(closeSummary) {
86
- await safeDelta(async () => {
87
- await store.endSession(sessionId, closeSummary);
88
- await store.close();
101
+ });
102
+ },
103
+ async recordAgentTool(input) {
104
+ if (!accepting) {
105
+ return;
106
+ }
107
+ await enqueue(async (store) => {
108
+ const session = await ensureSession(store);
109
+ if (!session) {
110
+ return;
111
+ }
112
+ await store.appendOperation({
113
+ sessionId: session.sessionId,
114
+ actorId: session.actorId,
115
+ kind: "agent.tool.called",
116
+ summary: `${input.toolName} ${input.status}`,
117
+ data: {
118
+ toolName: input.toolName,
119
+ risk: input.risk,
120
+ status: input.status,
121
+ traceId: input.traceId,
122
+ durationMs: input.durationMs,
123
+ },
89
124
  });
90
- },
91
- };
92
- } catch {
93
- return noopRecorder;
94
- }
125
+ });
126
+ },
127
+ async recordFileChanged(path, changeType = "modified") {
128
+ if (!accepting) {
129
+ return;
130
+ }
131
+ await enqueue(async (store) => {
132
+ const session = await ensureSession(store);
133
+ if (!session) {
134
+ return;
135
+ }
136
+ await store.recordFilePath(session.sessionId, path, changeType);
137
+ });
138
+ },
139
+ async close(closeSummary) {
140
+ if (closed) {
141
+ await queue;
142
+ return;
143
+ }
144
+ accepting = false;
145
+ closed = true;
146
+ await enqueue(async (store) => {
147
+ if (!sessionId) {
148
+ return;
149
+ }
150
+ await store.endSession(sessionId, closeSummary);
151
+ });
152
+ },
153
+ };
95
154
  }
96
155
 
97
156
  export async function recordParsedCliCommand(input: {
@@ -343,6 +402,25 @@ const noopRecorder: AmbientDeltaRecorder = {
343
402
  async close() {},
344
403
  };
345
404
 
405
+ const DELTA_STORE_RETRY_DELAYS_MS = [25, 75, 150];
406
+
407
+ async function openDeltaStoreWithRetry(workspaceRoot: string): Promise<DeltaStore> {
408
+ for (let attempt = 0; ; attempt += 1) {
409
+ try {
410
+ return await DeltaStore.open(workspaceRoot, { waitMs: 1_500, retryDelayMs: 50 });
411
+ } catch (error) {
412
+ if (!(error instanceof DeltaStoreBusyError) || attempt >= DELTA_STORE_RETRY_DELAYS_MS.length) {
413
+ throw error;
414
+ }
415
+ await sleep(DELTA_STORE_RETRY_DELAYS_MS[attempt] ?? 0);
416
+ }
417
+ }
418
+ }
419
+
420
+ function sleep(ms: number): Promise<void> {
421
+ return new Promise((resolve) => setTimeout(resolve, ms));
422
+ }
423
+
346
424
  async function safeDelta(fn: () => Promise<void>): Promise<void> {
347
425
  try {
348
426
  await fn();
@@ -348,6 +348,12 @@ interface DeltaStoreLock {
348
348
  token: string;
349
349
  }
350
350
 
351
+ export interface DeltaStoreOpenOptions {
352
+ access?: DeltaStoreAccess;
353
+ waitMs?: number;
354
+ retryDelayMs?: number;
355
+ }
356
+
351
357
  function getDeltaLockPath(workspaceRoot: string): string {
352
358
  return join(workspaceRoot, ".forge", "delta", "delta.lock");
353
359
  }
@@ -488,6 +494,33 @@ function acquireDeltaStoreLock(workspaceRoot: string): DeltaStoreLock {
488
494
  throw new DeltaStoreBusyError(lockPath, readLockHolder(lockPath));
489
495
  }
490
496
 
497
+ function sleep(ms: number): Promise<void> {
498
+ return new Promise((resolve) => setTimeout(resolve, ms));
499
+ }
500
+
501
+ async function acquireDeltaStoreLockWithWait(
502
+ workspaceRoot: string,
503
+ options: { waitMs?: number; retryDelayMs?: number } = {},
504
+ ): Promise<DeltaStoreLock> {
505
+ const waitMs = Math.max(0, options.waitMs ?? 0);
506
+ const retryDelayMs = Math.max(10, options.retryDelayMs ?? 50);
507
+ const started = Date.now();
508
+ for (;;) {
509
+ try {
510
+ return acquireDeltaStoreLock(workspaceRoot);
511
+ } catch (error) {
512
+ if (!(error instanceof DeltaStoreBusyError)) {
513
+ throw error;
514
+ }
515
+ const elapsed = Date.now() - started;
516
+ if (elapsed >= waitMs) {
517
+ throw error;
518
+ }
519
+ await sleep(Math.min(retryDelayMs, waitMs - elapsed));
520
+ }
521
+ }
522
+ }
523
+
491
524
  export function probeDeltaStoreBusy(workspaceRoot: string): DeltaStoreBusyError | null {
492
525
  const lockPath = getDeltaLockPath(workspaceRoot);
493
526
  if (!existsSync(lockPath)) {
@@ -551,11 +584,11 @@ export class DeltaStore {
551
584
  private readonly lock: DeltaStoreLock | null,
552
585
  ) {}
553
586
 
554
- static async open(workspaceRoot: string, options: { access?: DeltaStoreAccess } = {}): Promise<DeltaStore> {
587
+ static async open(workspaceRoot: string, options: DeltaStoreOpenOptions = {}): Promise<DeltaStore> {
555
588
  const storePath = getDeltaStorePath(workspaceRoot);
556
589
  mkdirSync(dirname(storePath), { recursive: true });
557
590
  const initializedBeforeOpen = deltaStoreInitialized(storePath);
558
- const lock = options.access === "read" ? null : acquireDeltaStoreLock(workspaceRoot);
591
+ const lock = options.access === "read" ? null : await acquireDeltaStoreLockWithWait(workspaceRoot, options);
559
592
  let store: DeltaStore | null = null;
560
593
  try {
561
594
  const adapter = await createPgliteAdapter(storePath);
@@ -565,7 +598,7 @@ export class DeltaStore {
565
598
  } else if (await store.needsSchemaInit()) {
566
599
  await store.close();
567
600
  store = null;
568
- const migrateLock = acquireDeltaStoreLock(workspaceRoot);
601
+ const migrateLock = await acquireDeltaStoreLockWithWait(workspaceRoot, options);
569
602
  try {
570
603
  const migrateAdapter = await createPgliteAdapter(storePath);
571
604
  store = new DeltaStore(workspaceRoot, storePath, migrateAdapter, migrateLock);
@@ -200,6 +200,13 @@ function chooseSchemaFile(workspaceRoot: string): string {
200
200
  return "src/forge/schema.ts";
201
201
  }
202
202
 
203
+ function schemaHasTenantsTable(workspaceRoot: string): boolean {
204
+ const schema = readIfExists(workspaceRoot, chooseSchemaFile(workspaceRoot)) ?? "";
205
+ return schema.includes('name: "tenants"') ||
206
+ schema.includes("name: 'tenants'") ||
207
+ /\btenants\s*=\s*(?:defineTable|table)\s*\(/.test(schema);
208
+ }
209
+
203
210
  function choosePolicyFile(workspaceRoot: string): string {
204
211
  if (fileExists(workspaceRoot, "src/policies.ts")) {
205
212
  return "src/policies.ts";
@@ -551,6 +558,8 @@ function buildIntent(options: MakeCommandOptions): {
551
558
  kind === "command"
552
559
  ? actionName?.replace(/^(create|update|delete)/, "") || "create"
553
560
  : "read";
561
+ const tenantScoped =
562
+ options.tenantScoped || (kind === "resource" && schemaHasTenantsTable(options.workspaceRoot));
554
563
 
555
564
  return {
556
565
  diagnostics,
@@ -560,7 +569,7 @@ function buildIntent(options: MakeCommandOptions): {
560
569
  table,
561
570
  field: fieldOptions.field,
562
571
  fields,
563
- tenantScoped: options.tenantScoped || kind === "resource",
572
+ tenantScoped,
564
573
  crud: options.withCrud || kind === "resource",
565
574
  liveQuery: options.withLiveQuery || kind === "resource" || kind === "livequery",
566
575
  react:
@@ -898,7 +907,7 @@ function buildPlan(options: MakeCommandOptions): MakePlan {
898
907
  kind: "resource" as const,
899
908
  name: options.name ?? "resource",
900
909
  fields: [],
901
- tenantScoped: true,
910
+ tenantScoped: false,
902
911
  crud: true,
903
912
  liveQuery: true,
904
913
  react: true,
@@ -1,3 +1,3 @@
1
- export const FORGEOS_VERSION = "0.1.0-alpha.24";
1
+ export const FORGEOS_VERSION = "0.1.0-alpha.26";
2
2
  export const GENERATOR_VERSION = FORGEOS_VERSION;
3
3
  export const CLI_VERSION = FORGEOS_VERSION;
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { normalizePath } from "../compiler/primitives/paths.ts";
4
5
  import {
5
6
  categorizeFiles,
6
7
  classifyChangeType,
@@ -13,6 +14,7 @@ import {
13
14
 
14
15
  export interface WorkspaceGitSummary {
15
16
  available: boolean;
17
+ source?: "git" | "filesystem";
16
18
  branch?: string;
17
19
  commit?: string;
18
20
  changed: FileListSummary;
@@ -28,18 +30,64 @@ export interface WorkspaceGitSummary {
28
30
  error?: string;
29
31
  }
30
32
 
31
- function emptySummary(error?: string): WorkspaceGitSummary {
33
+ const FALLBACK_IGNORED_DIRS = new Set([
34
+ ".git",
35
+ "node_modules",
36
+ "dist",
37
+ "build",
38
+ "coverage",
39
+ ".next",
40
+ ".nuxt",
41
+ ".turbo",
42
+ ".cache",
43
+ ]);
44
+
45
+ function listWorkspaceFiles(root: string): string[] {
46
+ const files: string[] = [];
47
+ const visit = (dir: string): void => {
48
+ let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }> = [];
49
+ try {
50
+ entries = readdirSync(dir, { withFileTypes: true });
51
+ } catch {
52
+ return;
53
+ }
54
+ for (const entry of entries) {
55
+ if (entry.isDirectory() && FALLBACK_IGNORED_DIRS.has(entry.name)) {
56
+ continue;
57
+ }
58
+ const absolute = join(dir, entry.name);
59
+ const rel = normalizePath(relative(root, absolute));
60
+ if (!rel || rel === ".") {
61
+ continue;
62
+ }
63
+ if (entry.isDirectory()) {
64
+ visit(absolute);
65
+ continue;
66
+ }
67
+ if (entry.isFile()) {
68
+ files.push(rel);
69
+ }
70
+ }
71
+ };
72
+ visit(root);
73
+ return filterVolatileForgeState(files).sort();
74
+ }
75
+
76
+ function filesystemSummary(workspaceRoot: string, error?: string): WorkspaceGitSummary {
77
+ const files = listWorkspaceFiles(workspaceRoot);
78
+ const classify = workspaceChangeClassifier(workspaceRoot);
32
79
  return {
33
80
  available: false,
34
- changed: compactFiles([]),
81
+ source: "filesystem",
82
+ changed: compactFiles(files),
35
83
  staged: compactFiles([]),
36
84
  unstaged: compactFiles([]),
37
- untracked: compactFiles([]),
85
+ untracked: compactFiles(files),
38
86
  changeSummary: {
39
- changed: categorizeFiles([]),
87
+ changed: categorizeFiles(files, 8, classify),
40
88
  staged: categorizeFiles([]),
41
89
  unstaged: categorizeFiles([]),
42
- untracked: categorizeFiles([]),
90
+ untracked: categorizeFiles(files, 8, classify),
43
91
  },
44
92
  ...(error ? { error } : {}),
45
93
  };
@@ -227,7 +275,7 @@ function parseStatusPath(line: string): string {
227
275
  export function buildWorkspaceGitSummary(workspaceRoot: string): WorkspaceGitSummary {
228
276
  const root = runGit(["rev-parse", "--show-toplevel"], workspaceRoot);
229
277
  if (!root.ok) {
230
- return emptySummary(root.error);
278
+ return filesystemSummary(workspaceRoot, root.error);
231
279
  }
232
280
 
233
281
  const status = runGit(["status", "--porcelain=v1", "-uall"], workspaceRoot, { trim: false });
@@ -262,6 +310,7 @@ export function buildWorkspaceGitSummary(workspaceRoot: string): WorkspaceGitSum
262
310
 
263
311
  return {
264
312
  available: true,
313
+ source: "git",
265
314
  ...(branch.ok ? { branch: branch.stdout } : {}),
266
315
  ...(commit.ok ? { commit: commit.stdout } : {}),
267
316
  changed: compactFiles(changedFiles),