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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,16 @@ It's also very important to keep everything very secure and go with the security
- When testing app-building flows, keep prompts intentionally tiny so runs finish quickly: ask for a tiny to-do list with minimal UI and no agents.
- Unless explicitly requested, send the message and verify the response starts successfully, but do not approve or complete the build.

# About local runtime detection
- Do not duplicate local-vs-cloud checks with ad hoc combinations of `SECOND_LOCAL_INSTALL`, `SECOND_AUTH_MODE`, or `readRuntimeConfig().authMode`. Use the shared helpers in `apps/web/src/lib/source-control/runtime.ts`, especially `sourceControlRuntimeLabel()` and `canShowLocalSourceControlFeatures()`, so Source Control settings, Available Apps, app-level publish controls, pages, and APIs agree on what "local" means.
- If a local-only feature appears in one surface but not another, inspect and fix the shared helper first instead of adding a component-local workaround.

# When making changes that are directly related to the desktop app:
- If the bug only appears in the packaged desktop app but not in `npx --yes @second-inc/cli` or browser localhost, first suspect desktop runtime environment differences such as PATH, app sandbox/signing, packaged resources, or lifecycle.
- For macOS provider subprocess bugs, remember Finder-launched apps do not inherit the user's terminal PATH; resolve CLI tools through the login shell or common install paths before changing provider logic.
- Do the smallest source fix plus quick validation, then hand the exact local build command to the human for manual app testing when they are actively testing the DMG/app.
- Do not keep running long DMG/notarization/build-test loops unless explicitly asked; stop once the code is ready for the requested manual test.
- If the human asks you to build it yourself then run, install and test it, run the following command: `cd /Users/omervexler/.codex/worktrees/<current-worktree>/second
SECOND_DESKTOP_SKIP_NOTARIZE=1 npm --prefix apps/desktop run make -- --mac dmg --arm64 --publish never` . The DMG will then be here: "/Users/omervexler/.codex/worktrees/<current-worktree>/second/apps/desktop/release/Second-0.2.0-mac-arm64.dmg"
- If the human asks you to build it yourself then run, install and test it, run the following command: `cd /Users/omervexler/.codex/worktrees/<current-worktree>/second && npm --prefix packages/cli ci && npm --prefix apps/desktop ci && npm --prefix packages/cli-local-darwin-arm64 run build && SECOND_DESKTOP_SKIP_NOTARIZE=1 npm --prefix apps/desktop run make -- --mac dmg --arm64 --publish never` . The DMG will then be here: "/Users/omervexler/.codex/worktrees/<current-worktree>/second/apps/desktop/release/Second-0.2.0-mac-arm64.dmg"

# About QA guides
- For broad manual QA, use the `QA/` folder. Keep a reusable date-prefixed E2E guide such as `QA/YYYY-MM-DD-E2E.md`, and create a separate date-prefixed task guide such as `QA/YYYY-MM-DD-<feature-or-merge>-qa.md` for the current feature, branch, or merge.
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ app.on("before-quit", (event) => {

function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1240,
height: 820,
width: 1320,
height: 865,
title: "Second",
show: false,
...(process.platform === "darwin"
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/icons/source-control-bitbucket.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/web/public/icons/source-control-github.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions apps/web/public/icons/source-control-gitlab.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 28 additions & 13 deletions apps/web/src/app/api/onboarding/identity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import {
buildNoAuthSessionCookie,
buildWorkspaceCookie,
IDENTITY_ONBOARDING_PATH,
LOCAL_ONBOARDING_EMAIL,
readNoAuthSessionUserId,
WORKSPACE_ONBOARDING_PATH,
} from "@/lib/auth";
import { readRuntimeConfig } from "@/lib/config";
import {
findUserById,
listMembershipsForUser,
updateUserOnboarding,
updateUserProfile,
upsertUserByEmail,
} from "@/lib/db";
import { userCompletedOnboarding } from "@/lib/onboarding";
import {
validateDisplayName,
validateEmail,
validateOptionalProfileRole,
validateProfileRole,
} from "@/lib/validation";

export async function POST(request: Request) {
Expand All @@ -28,24 +31,36 @@ export async function POST(request: Request) {

const formData = await request.formData();
const displayName = validateDisplayName(formData.get("displayName"));
const email = validateEmail(formData.get("email"));
const rawProfileRole = formData.get("profileRole");
const profileRole = validateOptionalProfileRole(rawProfileRole);
const rawProfileRoleText =
typeof rawProfileRole === "string" ? rawProfileRole.trim() : "";
const hasInvalidProfileRole =
rawProfileRole !== null &&
(typeof rawProfileRole !== "string" ||
(rawProfileRoleText.length > 0 && profileRole === null));
const profileRole = validateProfileRole(formData.get("profileRole"));

if (!displayName || !email || hasInvalidProfileRole) {
if (!displayName || !profileRole) {
return NextResponse.redirect(
new URL(`${IDENTITY_ONBOARDING_PATH}?error=invalid_identity`, config.publicUrl),
303,
);
}

const user = await upsertUserByEmail({ displayName, email, profileRole });
const existingSessionUserId = readNoAuthSessionUserId(request.headers);
const existingUser = existingSessionUserId
? await findUserById(existingSessionUserId)
: null;
const user = existingUser
? await updateUserProfile({
userId: existingUser._id,
displayName,
email: existingUser.email || LOCAL_ONBOARDING_EMAIL,
profileRole,
})
: await upsertUserByEmail({
displayName,
email: LOCAL_ONBOARDING_EMAIL,
profileRole,
});

if (!user) {
throw new Error("[onboarding] Failed to save local identity.");
}

const memberships = await listMembershipsForUser(user._id);

let destination = WORKSPACE_ONBOARDING_PATH;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/setup/detect-provider/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type DetectionResult = {
envKeyConfigured: boolean;
cliLikelyConfigured: boolean;
localAuthConfigured?: boolean;
modelsDiscovered?: boolean;
};
}
>;
Expand Down Expand Up @@ -65,6 +66,7 @@ function workerUnavailableProviderResult(error: string): DetectionResult {
),
cliLikelyConfigured: false,
localAuthConfigured: false,
modelsDiscovered: false,
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
failRun,
findWorkspaceById,
findRunnableWorkspaceAgentForViewer,
getAppSourceFiles,
loadRuntimeSkillsByRefs,
loadRunForApp,
loadRunStreamStateForApp,
Expand All @@ -32,6 +31,10 @@ import {
resolveRuntimeSkillsForViewer,
type StartRunStreamResult,
} from "@/lib/db";
import {
restoreSourceControlFilesForApp,
syncAppSnapshotToSourceControl,
} from "@/lib/source-control/sync-app";
import { classifyBuilderRunTerminalState } from "@/lib/agent/builder-run-terminal";
import {
isWorkerRestoreNeeded,
Expand Down Expand Up @@ -886,7 +889,7 @@ export async function POST(request: Request, context: ChatRouteContext) {
? await isWorkerRestoreNeeded(workerUrl, appId)
: false;
const existingSourceFiles = restoreNeeded
? await getAppSourceFiles({
? await restoreSourceControlFilesForApp({
workspaceId: workspaceContext.workspaceId,
appId,
})
Expand Down Expand Up @@ -1138,6 +1141,28 @@ export async function POST(request: Request, context: ChatRouteContext) {
appId,
sourceFiles: bridgeResult.sourceFiles,
});
after(() => {
void syncAppSnapshotToSourceControl({
workspaceId: workspaceContext.workspaceId,
appId,
files: bridgeResult.sourceFiles!,
summary: bridgeResult.doneBuilding?.summary ?? null,
audit: {
actor: {
kind: "agent",
agentName: "Builder agent",
},
source: auditSourceFromRequest(request, {
kind: "builder_agent",
trust: "internal_trusted",
appId,
appName: app.name,
runId,
}),
runId,
},
});
});
const snapshot = sourceSnapshotMetadata(bridgeResult.sourceFiles);
void recordAuditEvent({
workspaceId: workspaceContext.workspaceId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { NextResponse } from "next/server";
import {
guardErrorToApiResponse,
isRequestGuardError,
requireWorkspaceContext,
resolveAppAccess,
} from "@/lib/auth";
import { getAppSourceFilesForVersion } from "@/lib/db";
import { canShowLocalSourceControlFeatures } from "@/lib/source-control/runtime";
import { publishAppToSourceControl } from "@/lib/source-control/sync-app";
import { workerFetch } from "@/lib/worker-client";

type PublishSourceControlRouteContext = {
params: Promise<{
workspaceId: string;
appId: string;
}>;
};

function isStringRecord(value: unknown): value is Record<string, string> {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.values(value).every((entry) => typeof entry === "string")
);
}

async function getLiveWorkerFiles(
appId: string,
): Promise<Record<string, string> | null> {
try {
const res = await workerFetch(`/sessions/${appId}/files`, {
cache: "no-store",
});
if (!res.ok) return null;
const data = (await res.json()) as { files?: unknown };
return isStringRecord(data.files) ? data.files : null;
} catch {
return null;
}
}

function mergeFiles(
persistedFiles: Record<string, string> | null,
liveFiles: Record<string, string> | null,
): Record<string, string> | null {
if (persistedFiles && liveFiles) return { ...persistedFiles, ...liveFiles };
return liveFiles ?? persistedFiles;
}

export async function POST(
request: Request,
context: PublishSourceControlRouteContext,
) {
const { workspaceId, appId } = await context.params;
const url = new URL(request.url);
let workspaceContext: Awaited<ReturnType<typeof requireWorkspaceContext>>;
try {
workspaceContext = await requireWorkspaceContext({
headers: request.headers,
pathname: url.pathname,
workspaceId,
});
} catch (error) {
if (isRequestGuardError(error)) return guardErrorToApiResponse(error);
throw error;
}

if (!canShowLocalSourceControlFeatures()) {
return NextResponse.json({ error: "local_runtime_required" }, { status: 404 });
}

const access = await resolveAppAccess({ workspaceContext, appId });
if (!access) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
if (!access.canCollaborate) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}

const [persistedFiles, liveFiles] = await Promise.all([
getAppSourceFilesForVersion({
workspaceId: workspaceContext.workspaceId,
appId,
version: "draft",
}),
getLiveWorkerFiles(appId),
]);
const files = mergeFiles(persistedFiles, liveFiles);
if (!files || Object.keys(files).length === 0) {
return NextResponse.json({ error: "no_source_files" }, { status: 404 });
}

const result = await publishAppToSourceControl({
workspaceContext,
request,
appId,
files,
});
if (result.status === "failed") {
return NextResponse.json(
{ error: result.code, message: result.message },
{ status: 400 },
);
}
if (result.status === "skipped") {
return NextResponse.json(
{ status: result.status, reason: result.reason },
{ status: 202 },
);
}

return NextResponse.json({ status: "published", sourceControl: result });
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
appHasPublishedVersion,
appHasUnpublishedChanges,
getAppPublishStatus,
getSourceControlConnection,
findPendingAppReviewRequest,
getWorkspaceAppRuntimeSettings,
integrationNeedsSetup,
listIntegrationsForAppReview,
} from "@/lib/db";
import { canShowLocalSourceControlFeatures } from "@/lib/source-control/runtime";

type AppStateRouteContext = {
params: Promise<{
Expand Down Expand Up @@ -50,7 +52,13 @@ export async function GET(request: Request, context: AppStateRouteContext) {
const visiblePublishStatus =
canSeeDraftState || !hasPublishedVersion ? publishStatus : "published";
const visibleHasDraftChanges = canSeeDraftState ? hasDraftChanges : false;
const [appRuntimeSettings, pendingReview, integrations] = await Promise.all([
const localSourceControlAvailable = canShowLocalSourceControlFeatures();
const [
appRuntimeSettings,
pendingReview,
integrations,
sourceControlConnection,
] = await Promise.all([
getWorkspaceAppRuntimeSettings(workspaceContext.workspaceId),
canSeeDraftState
? findPendingAppReviewRequest({
Expand All @@ -64,6 +72,12 @@ export async function GET(request: Request, context: AppStateRouteContext) {
appId,
})
: Promise.resolve([]),
localSourceControlAvailable
? getSourceControlConnection({
workspaceId: workspaceContext.workspaceId,
provider: "github",
})
: Promise.resolve(null),
]);
const visibleAppTeamIds = canSeeDraftState
? (pendingReview?.targetTeamIds ?? app.teamIds ?? [])
Expand All @@ -86,6 +100,29 @@ export async function GET(request: Request, context: AppStateRouteContext) {
hasPublishedVersion,
hasDraftChanges: visibleHasDraftChanges,
appRuntimeSettings,
sourceControl: {
localAvailable: localSourceControlAvailable,
connected: sourceControlConnection?.status === "valid",
connectionStatus: sourceControlConnection?.status ?? "not_configured",
canPublish:
localSourceControlAvailable &&
sourceControlConnection?.status === "valid" &&
access.canCollaborate,
app: app.sourceControl
? {
publishEnabled: Boolean(app.sourceControl.publishEnabled),
publishState: app.sourceControl.publishState ?? null,
syncStatus: app.sourceControl.syncStatus,
owner: app.sourceControl.owner,
repo: app.sourceControl.repo,
latestTag: app.sourceControl.latestTag ?? null,
version: app.sourceControl.version ?? null,
lastSyncedAt:
app.sourceControl.lastSyncedAt?.toISOString() ?? null,
lastErrorMessage: app.sourceControl.lastErrorMessage ?? null,
}
: null,
},
integrations: integrations.map((integration) => {
return {
id: integration._id,
Expand Down
Loading
Loading