Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/deploy/e2e/docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ services:
# P2 slice 3: Production refuses to start with unconfigured durable roots — the exact discipline this
# smoke stack must model. The images prepare these dirs with app ownership; a real deployment mounts volumes at them.
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
Artifacts__LocalRwxShared: "true"
Agents__RunSpoolDirectory: /var/lib/codespace/spool
volumes:
- artifacts:/var/lib/codespace/artifacts
Expand All @@ -59,6 +60,7 @@ services:
Authentication__Jwt__SymmetricKey: "${E2E_JWT_KEY}"
CODESPACE_VARIABLE_MASTER_KEY: "${E2E_VARIABLE_MASTER_KEY}"
Artifacts__StoreDirectory: /var/lib/codespace/artifacts
Artifacts__LocalRwxShared: "true"
Agents__RunSpoolDirectory: /var/lib/codespace/spool
HangfireHosting: "Worker"
CODESPACE_CODEX_CLI_PATH: "/opt/fake-codex"
Expand Down
5 changes: 3 additions & 2 deletions backend/src/CodeSpace.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@
"PackAllowedHosts": ""
},
"Artifacts": {
"_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.",
"StoreDirectory": ""
"_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.",
"StoreDirectory": "",
"LocalRwxShared": false
},
"Shutdown": {
"_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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.Text.Json;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
using CodeSpace.Core.Settings;
using CodeSpace.Messages.Constants;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace CodeSpace.Core.Services.Agents.AgentRunLogging;

/// <summary>
/// Gives an unconfigured team one real Settings-visible local route only after the deployment explicitly qualifies its
/// local-rwx namespace as shared. This is a missing-only bootstrap, not a routing fallback: an existing route in any
/// lifecycle state or any collision on the reserved profile name remains authoritative, and later Settings revisions can
/// move the data class to any registered cloud provider without changing Agent Run capture.
/// </summary>
public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness
{
internal const string DefaultProfileStableName = "codespace-agent-run-log-default";
private const int AdvisoryLockNamespace = 117;
private readonly CodeSpaceDbContext _db;
private readonly TimeProvider _clock;

public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock)
{
_db = db;
_clock = clock;
}

public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
{
if (teamId == Guid.Empty || !RuntimeSettings.Current.ArtifactLocalRwxShared) return;

await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({teamId.ToString()}, {AdvisoryLockNamespace}))", cancellationToken).ConfigureAwait(false);
if (!await _db.Team.AsNoTracking().AnyAsync(team => team.Id == teamId, cancellationToken).ConfigureAwait(false)
|| await _db.StorageRoute.AsNoTracking().AnyAsync(route => route.TeamId == teamId && route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey, cancellationToken).ConfigureAwait(false))
{
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return;
}

var rootPath = DurableRoots.ArtifactStore(RuntimeSettings.Current.ArtifactStoreDirectory);
var canonicalConfig = CanonicalConfig(rootPath);
if (await _db.StorageProfile.AsNoTracking().AnyAsync(
value => value.TeamId == teamId && value.StableName == DefaultProfileStableName, cancellationToken).ConfigureAwait(false))
{
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return;
}

var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow());
_db.StorageProfile.Add(profile);
var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow());
_db.StorageRoute.Add(route);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
route.State = StorageRouteState.Active;
route.LastModifiedDate = _clock.GetUtcNow();
route.LastModifiedBy = SystemUsers.SeederId;
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (IsUniqueViolation(exception))
{
await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
_db.ChangeTracker.Clear();
}
}

private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, DateTimeOffset now)
{
var profile = new StorageProfile
{
Id = Guid.NewGuid(), TeamId = teamId, StableName = DefaultProfileStableName, CurrentRevision = 1,
State = StorageProfileState.Active, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
};
using var document = JsonDocument.Parse(canonicalConfig);
profile.Revisions.Add(new StorageProfileRevision
{
Id = Guid.NewGuid(), TeamId = teamId, StorageProfileId = profile.Id, Revision = 1,
ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey, NonSecretConfigJson = canonicalConfig,
CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, document.RootElement),
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
});
return profile;
}

private static StorageRoute BuildRoute(Guid teamId, Guid profileId, DateTimeOffset now)
{
var route = new StorageRoute
{
Id = Guid.NewGuid(), TeamId = teamId, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey,
CurrentRevision = 1, State = StorageRouteState.Draft, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
};
route.Revisions.Add(new StorageRouteRevision
{
Id = Guid.NewGuid(), TeamId = teamId, StorageRouteId = route.Id, Revision = 1, StorageProfileId = profileId,
ProfileRevisionMode = StorageProfileRevisionMode.CurrentAtWrite, PinnedProfileRevision = null,
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
});
return route;
}

private static string CanonicalConfig(string rootPath)
{
using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath }));
return StorageProfileRules.CanonicalJson(document.RootElement);
}

private static bool IsUniqueViolation(Exception exception)
{
for (Exception? current = exception; current != null; current = current.InnerException)
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) return true;
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ public sealed class AgentRunLogStorageResolver : IAgentRunLogStorageResolver
{
public const string DataClassTypeKey = "agent-run-log/v1";
private readonly IStorageRouteSnapshotResolver _routes;
private readonly IAgentRunLogStorageReadiness _readiness;

public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes) => _routes = routes;
public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes, IAgentRunLogStorageReadiness readiness)
{
_routes = routes;
_readiness = readiness;
}

public async Task<AgentRunLogStorageResolution> ResolveAsync(Guid teamId, CancellationToken cancellationToken)
{
var resolution = await _routes.ResolveAsync(new StorageRouteSnapshotRequest(teamId, DataClassTypeKey), cancellationToken).ConfigureAwait(false);
var request = new StorageRouteSnapshotRequest(teamId, DataClassTypeKey);
var resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
throw new OperationCanceledException(cancellationToken);
if (resolution is StorageRouteSnapshotResolution.Missing)
{
await _readiness.EnsureDefaultRouteAsync(teamId, cancellationToken).ConfigureAwait(false);
resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false);
if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested)
throw new OperationCanceledException(cancellationToken);
}

return resolution switch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ public interface IAgentRunLogStorageResolver : IScopedDependency
Task<AgentRunLogStorageResolution> ResolveAsync(Guid teamId, CancellationToken cancellationToken);
}

/// <summary>
/// Establishes the explicit Settings-visible default route for a team that has never configured Agent Run log storage,
/// but only when the deployment explicitly qualified local-rwx as shared. Implementations may act only on a missing
/// exact route; lifecycle states and reserved stable names chosen by an operator are final.
/// </summary>
public interface IAgentRunLogStorageReadiness : IScopedDependency
{
Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken);
}

public abstract record AgentRunLogStorageResolution
{
private AgentRunLogStorageResolution() { }
Expand Down
8 changes: 8 additions & 0 deletions backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ public sealed record RuntimeSettings
/// <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>
public string? ArtifactStoreDirectory { get; init; }

/// <summary>
/// Explicit deployment qualification that the local-rwx artifact root is one shared namespace visible to every API
/// and worker instance that may use it. False by default: a durable-looking or existing path does not prove two
/// hosts see the same bytes, so it cannot authorize an automatically active team route.
/// </summary>
public bool ArtifactLocalRwxShared { get; init; }

/// <summary>
/// Graceful-shutdown drain budget in seconds — how long the host waits on SIGTERM for in-flight background work
/// before exiting. The orchestrator's own grace period MUST be at least this (k8s
Expand Down Expand Up @@ -78,6 +85,7 @@ public sealed record RuntimeSettings
AgentCgroupRoot = Trimmed(configuration["Sandbox:CgroupRoot"]),
AgentRunSpoolDirectory = Trimmed(configuration["Agents:RunSpoolDirectory"]),
ArtifactStoreDirectory = Trimmed(configuration["Artifacts:StoreDirectory"]),
ArtifactLocalRwxShared = configuration.GetValue("Artifacts:LocalRwxShared", false),
ShutdownDrainSeconds = Positive(configuration["Shutdown:DrainSeconds"], DefaultShutdownDrainSeconds),
PackAllowedHosts = Trimmed(configuration["Agents:PackAllowedHosts"]),
// Secrets. The LEGACY flat keys are still honoured, and that is load-bearing rather than tidy: every
Expand Down
Loading
Loading