Skip to content

Commit a67a81e

Browse files
committed
fix(agent-logs): bootstrap exact local storage route
1 parent c26f428 commit a67a81e

11 files changed

Lines changed: 433 additions & 15 deletions

File tree

‎backend/deploy/e2e/docker-compose.e2e.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ services:
3636
# P2 slice 3: Production refuses to start with unconfigured durable roots — the exact discipline this
3737
# smoke stack must model. The images prepare these dirs with app ownership; a real deployment mounts volumes at them.
3838
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
39+
Artifacts__LocalRwxShared: "true"
3940
Agents__RunSpoolDirectory: /var/lib/codespace/spool
4041
volumes:
4142
- artifacts:/var/lib/codespace/artifacts
@@ -59,6 +60,7 @@ services:
5960
Authentication__Jwt__SymmetricKey: "${E2E_JWT_KEY}"
6061
CODESPACE_VARIABLE_MASTER_KEY: "${E2E_VARIABLE_MASTER_KEY}"
6162
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
63+
Artifacts__LocalRwxShared: "true"
6264
Agents__RunSpoolDirectory: /var/lib/codespace/spool
6365
HangfireHosting: "Worker"
6466
CODESPACE_CODEX_CLI_PATH: "/opt/fake-codex"

‎backend/src/CodeSpace.Api/appsettings.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@
5858
"PackAllowedHosts": ""
5959
},
6060
"Artifacts": {
61-
"_doc": "Root directory for offloaded artifact bytes. Blank uses a path under the system temp dir; point it at a persistent volume in any deployment whose artifacts must outlive the pod.",
62-
"StoreDirectory": ""
61+
"_doc": "StoreDirectory is the root for offloaded artifact bytes. LocalRwxShared is a separate deployment qualification: true means every API/worker instance using local-rwx sees the same namespace (for example one RWX volume). A durable-looking path alone is not proof, so false is the fail-closed default.",
62+
"StoreDirectory": "",
63+
"LocalRwxShared": false
6364
},
6465
"Shutdown": {
6566
"_doc": "Graceful-shutdown drain budget in seconds. The orchestrator's grace period MUST be at least this or the process is SIGKILLed before in-flight background work drains (k8s: terminationGracePeriodSeconds). 30 matches k8s's own default.",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using System.Text.Json;
2+
using CodeSpace.Core.Persistence.Db;
3+
using CodeSpace.Core.Persistence.Entities;
4+
using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
5+
using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
6+
using CodeSpace.Core.Settings;
7+
using CodeSpace.Messages.Constants;
8+
using Microsoft.EntityFrameworkCore;
9+
using Npgsql;
10+
11+
namespace CodeSpace.Core.Services.Agents.AgentRunLogging;
12+
13+
/// <summary>
14+
/// Gives an unconfigured team one real Settings-visible local route only after the deployment explicitly qualifies its
15+
/// local-rwx namespace as shared. This is a missing-only bootstrap, not a routing fallback: an existing route in any
16+
/// lifecycle state or any collision on the reserved profile name remains authoritative, and later Settings revisions can
17+
/// move the data class to any registered cloud provider without changing Agent Run capture.
18+
/// </summary>
19+
public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness
20+
{
21+
internal const string DefaultProfileStableName = "codespace-agent-run-log-default";
22+
private const int AdvisoryLockNamespace = 117;
23+
private readonly CodeSpaceDbContext _db;
24+
private readonly TimeProvider _clock;
25+
26+
public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock)
27+
{
28+
_db = db;
29+
_clock = clock;
30+
}
31+
32+
public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
33+
{
34+
if (teamId == Guid.Empty || !RuntimeSettings.Current.ArtifactLocalRwxShared) return;
35+
36+
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
37+
try
38+
{
39+
await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({teamId.ToString()}, {AdvisoryLockNamespace}))", cancellationToken).ConfigureAwait(false);
40+
if (!await _db.Team.AsNoTracking().AnyAsync(team => team.Id == teamId, cancellationToken).ConfigureAwait(false)
41+
|| await _db.StorageRoute.AsNoTracking().AnyAsync(route => route.TeamId == teamId && route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey, cancellationToken).ConfigureAwait(false))
42+
{
43+
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
44+
return;
45+
}
46+
47+
var rootPath = DurableRoots.ArtifactStore(RuntimeSettings.Current.ArtifactStoreDirectory);
48+
var canonicalConfig = CanonicalConfig(rootPath);
49+
if (await _db.StorageProfile.AsNoTracking().AnyAsync(
50+
value => value.TeamId == teamId && value.StableName == DefaultProfileStableName, cancellationToken).ConfigureAwait(false))
51+
{
52+
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
53+
return;
54+
}
55+
56+
var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow());
57+
_db.StorageProfile.Add(profile);
58+
var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow());
59+
_db.StorageRoute.Add(route);
60+
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
61+
route.State = StorageRouteState.Active;
62+
route.LastModifiedDate = _clock.GetUtcNow();
63+
route.LastModifiedBy = SystemUsers.SeederId;
64+
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
65+
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
66+
}
67+
catch (Exception exception) when (IsUniqueViolation(exception))
68+
{
69+
await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
70+
_db.ChangeTracker.Clear();
71+
}
72+
}
73+
74+
private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, DateTimeOffset now)
75+
{
76+
var profile = new StorageProfile
77+
{
78+
Id = Guid.NewGuid(), TeamId = teamId, StableName = DefaultProfileStableName, CurrentRevision = 1,
79+
State = StorageProfileState.Active, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
80+
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
81+
};
82+
using var document = JsonDocument.Parse(canonicalConfig);
83+
profile.Revisions.Add(new StorageProfileRevision
84+
{
85+
Id = Guid.NewGuid(), TeamId = teamId, StorageProfileId = profile.Id, Revision = 1,
86+
ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey, NonSecretConfigJson = canonicalConfig,
87+
CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, document.RootElement),
88+
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
89+
});
90+
return profile;
91+
}
92+
93+
private static StorageRoute BuildRoute(Guid teamId, Guid profileId, DateTimeOffset now)
94+
{
95+
var route = new StorageRoute
96+
{
97+
Id = Guid.NewGuid(), TeamId = teamId, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey,
98+
CurrentRevision = 1, State = StorageRouteState.Draft, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
99+
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
100+
};
101+
route.Revisions.Add(new StorageRouteRevision
102+
{
103+
Id = Guid.NewGuid(), TeamId = teamId, StorageRouteId = route.Id, Revision = 1, StorageProfileId = profileId,
104+
ProfileRevisionMode = StorageProfileRevisionMode.CurrentAtWrite, PinnedProfileRevision = null,
105+
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
106+
});
107+
return route;
108+
}
109+
110+
private static string CanonicalConfig(string rootPath)
111+
{
112+
using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath }));
113+
return StorageProfileRules.CanonicalJson(document.RootElement);
114+
}
115+
116+
private static bool IsUniqueViolation(Exception exception)
117+
{
118+
for (Exception? current = exception; current != null; current = current.InnerException)
119+
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) return true;
120+
return false;
121+
}
122+
}

‎backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,27 @@ public sealed class AgentRunLogStorageResolver : IAgentRunLogStorageResolver
1010
{
1111
public const string DataClassTypeKey = "agent-run-log/v1";
1212
private readonly IStorageRouteSnapshotResolver _routes;
13+
private readonly IAgentRunLogStorageReadiness _readiness;
1314

14-
public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes) => _routes = routes;
15+
public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes, IAgentRunLogStorageReadiness readiness)
16+
{
17+
_routes = routes;
18+
_readiness = readiness;
19+
}
1520

1621
public async Task<AgentRunLogStorageResolution> ResolveAsync(Guid teamId, CancellationToken cancellationToken)
1722
{
18-
var resolution = await _routes.ResolveAsync(new StorageRouteSnapshotRequest(teamId, DataClassTypeKey), cancellationToken).ConfigureAwait(false);
23+
var request = new StorageRouteSnapshotRequest(teamId, DataClassTypeKey);
24+
var resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
1925
if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
2026
throw new OperationCanceledException(cancellationToken);
27+
if (resolution is StorageRouteSnapshotResolution.Missing)
28+
{
29+
await _readiness.EnsureDefaultRouteAsync(teamId, cancellationToken).ConfigureAwait(false);
30+
resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
31+
if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
32+
throw new OperationCanceledException(cancellationToken);
33+
}
2134

2235
return resolution switch
2336
{

‎backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ public interface IAgentRunLogStorageResolver : IScopedDependency
4848
Task<AgentRunLogStorageResolution> ResolveAsync(Guid teamId, CancellationToken cancellationToken);
4949
}
5050

51+
/// <summary>
52+
/// Establishes the explicit Settings-visible default route for a team that has never configured Agent Run log storage,
53+
/// but only when the deployment explicitly qualified local-rwx as shared. Implementations may act only on a missing
54+
/// exact route; lifecycle states and reserved stable names chosen by an operator are final.
55+
/// </summary>
56+
public interface IAgentRunLogStorageReadiness : IScopedDependency
57+
{
58+
Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken);
59+
}
60+
5161
public abstract record AgentRunLogStorageResolution
5262
{
5363
private AgentRunLogStorageResolution() { }

‎backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ public sealed record RuntimeSettings
3939
/// <summary>Root directory for offloaded artifact bytes. Null ⇒ a path under the system temp dir. Same durability caveat as the spool: point it at a persistent volume in any deployment whose artifacts must outlive the pod.</summary>
4040
public string? ArtifactStoreDirectory { get; init; }
4141

42+
/// <summary>
43+
/// Explicit deployment qualification that the local-rwx artifact root is one shared namespace visible to every API
44+
/// and worker instance that may use it. False by default: a durable-looking or existing path does not prove two
45+
/// hosts see the same bytes, so it cannot authorize an automatically active team route.
46+
/// </summary>
47+
public bool ArtifactLocalRwxShared { get; init; }
48+
4249
/// <summary>
4350
/// Graceful-shutdown drain budget in seconds — how long the host waits on SIGTERM for in-flight background work
4451
/// before exiting. The orchestrator's own grace period MUST be at least this (k8s
@@ -78,6 +85,7 @@ public sealed record RuntimeSettings
7885
AgentCgroupRoot = Trimmed(configuration["Sandbox:CgroupRoot"]),
7986
AgentRunSpoolDirectory = Trimmed(configuration["Agents:RunSpoolDirectory"]),
8087
ArtifactStoreDirectory = Trimmed(configuration["Artifacts:StoreDirectory"]),
88+
ArtifactLocalRwxShared = configuration.GetValue("Artifacts:LocalRwxShared", false),
8189
ShutdownDrainSeconds = Positive(configuration["Shutdown:DrainSeconds"], DefaultShutdownDrainSeconds),
8290
PackAllowedHosts = Trimmed(configuration["Agents:PackAllowedHosts"]),
8391
// Secrets. The LEGACY flat keys are still honoured, and that is load-bearing rather than tidy: every

0 commit comments

Comments
 (0)