@bevel-software/platform-core-backend 0.12.0 → 0.13.1
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/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -49,11 +49,12 @@ import type { GitService } from './git/git.service.js';
|
|
|
49
49
|
import type { PullRequestService } from './git/pull-request.service.js';
|
|
50
50
|
import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
|
|
51
51
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
52
|
-
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
52
|
+
import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
53
53
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
54
54
|
import { FileLockService } from './file-lock.service.js';
|
|
55
55
|
import { PendingCommitsService } from './pending-commits.service.js';
|
|
56
56
|
import type { WorkflowEventBus } from './event-bus.js';
|
|
57
|
+
import { sanitizeError } from './sanitize-error.js';
|
|
57
58
|
import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
|
|
58
59
|
import { WorkflowHooks } from './workflow-hooks.js';
|
|
59
60
|
import { WorkspaceMutex } from '../kb-fs/mutex.js';
|
|
@@ -355,7 +356,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
355
356
|
// to discard.
|
|
356
357
|
|
|
357
358
|
shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
|
|
358
|
-
return this.
|
|
359
|
+
return this.trackedPush(workspaceId, user);
|
|
359
360
|
}
|
|
360
361
|
|
|
361
362
|
refreshRemotes(workspaceId: string): Promise<void> {
|
|
@@ -680,6 +681,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
680
681
|
if (change) {
|
|
681
682
|
try {
|
|
682
683
|
await this.git.push(workspaceId, user);
|
|
684
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
683
685
|
} catch (err) {
|
|
684
686
|
// Non-fast-forward recovery on the autosave path: try the
|
|
685
687
|
// cooperative `pull --rebase` + retry once. Autosave is
|
|
@@ -695,12 +697,15 @@ export class WorkflowService implements IWorkflowService {
|
|
|
695
697
|
const detail = err instanceof Error ? err.message : String(err);
|
|
696
698
|
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(detail);
|
|
697
699
|
let recovered = false;
|
|
700
|
+
let recoveryError: unknown = null;
|
|
698
701
|
if (looksLikeNonFastForward) {
|
|
699
702
|
try {
|
|
700
703
|
await this.git.pull(workspaceId);
|
|
701
704
|
await this.git.push(workspaceId, user);
|
|
702
705
|
recovered = true;
|
|
706
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
703
707
|
} catch (recoveryErr) {
|
|
708
|
+
recoveryError = recoveryErr;
|
|
704
709
|
console.warn(
|
|
705
710
|
'[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
|
|
706
711
|
recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
|
|
@@ -708,6 +713,20 @@ export class WorkflowService implements IWorkflowService {
|
|
|
708
713
|
}
|
|
709
714
|
}
|
|
710
715
|
if (!recovered) {
|
|
716
|
+
// Still best-effort — we don't fail the autosave. What gets the
|
|
717
|
+
// BANNER is only the class that cannot clear itself: auth or host
|
|
718
|
+
// failures, which no later attempt fixes. A non-fast-forward here —
|
|
719
|
+
// recovery attempted or not — is the routine two-editors race the
|
|
720
|
+
// surrounding flow already handles: the release push retries it,
|
|
721
|
+
// and if THAT fails the user gets the typed agent hand-off plus the
|
|
722
|
+
// banner from `pushWithRecovery`. Bannering it from a background
|
|
723
|
+
// autosave would tell an author mid-keystroke that the deployment
|
|
724
|
+
// is broken over a race that usually settles on its own — while an
|
|
725
|
+
// auth failure swallowed into a log line is how a broken deployment
|
|
726
|
+
// once stacked 136 local commits before anyone found out.
|
|
727
|
+
if (!looksLikeNonFastForward) {
|
|
728
|
+
this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? err);
|
|
729
|
+
}
|
|
711
730
|
console.warn(
|
|
712
731
|
'[workflow] push after autosave commit failed (commit landed locally):',
|
|
713
732
|
detail,
|
|
@@ -896,6 +915,87 @@ export class WorkflowService implements IWorkflowService {
|
|
|
896
915
|
this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
|
|
897
916
|
}
|
|
898
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Workspaces whose last push failed. Purely so the recovery event fires on
|
|
920
|
+
* the transition back to healthy instead of on every subsequent save — a
|
|
921
|
+
* push runs on every write, and the healthy case must stay silent.
|
|
922
|
+
*
|
|
923
|
+
* Process-local and deliberately not persisted: a restart clears it, and the
|
|
924
|
+
* worst that costs is one redundant `git-sync-recovered` on a workspace that
|
|
925
|
+
* was already fine, which clients treat as a no-op.
|
|
926
|
+
*/
|
|
927
|
+
private readonly gitSyncFailing = new Set<string>();
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Announce that a push landed. Emits only when this workspace was previously
|
|
931
|
+
* failing — see `gitSyncFailing`.
|
|
932
|
+
*/
|
|
933
|
+
private noteGitSyncOk(workspaceId: string, branch: string): void {
|
|
934
|
+
// Callers hand this both spellings of a workspace id — HTTP routes pass
|
|
935
|
+
// the Express-decoded `user/feat`, internal callers (the pending-commits
|
|
936
|
+
// worker, the agent filesystem) the encoded `user%2Ffeat`. Keyed raw, a
|
|
937
|
+
// failure recorded by one caller could never be cleared by the other's
|
|
938
|
+
// success, and the banner would stick. Canonicalize the key AND the
|
|
939
|
+
// emitted id so every consumer sees one spelling.
|
|
940
|
+
const id = branchForWorkspaceId(workspaceId);
|
|
941
|
+
if (!this.gitSyncFailing.delete(id)) return;
|
|
942
|
+
console.log(`[workflow] git sync recovered for workspace=${id} branch=${branch}`);
|
|
943
|
+
this.events?.emit({ kind: 'git-sync-recovered', workspaceId: id, branch });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Announce that a commit is sitting locally because its push failed.
|
|
948
|
+
*
|
|
949
|
+
* This is the visibility half of the failure paths below; it never changes
|
|
950
|
+
* what they DO. The commit is already safe on disk and the retry / hand-off
|
|
951
|
+
* machinery is unaffected — all this adds is that someone finds out, rather
|
|
952
|
+
* than the deployment looking healthy while nothing reaches the remote.
|
|
953
|
+
*/
|
|
954
|
+
private noteGitSyncFailed(workspaceId: string, branch: string, err: unknown): void {
|
|
955
|
+
// Same canonicalization as `noteGitSyncOk` — the pair must agree on keys.
|
|
956
|
+
const id = branchForWorkspaceId(workspaceId);
|
|
957
|
+
this.gitSyncFailing.add(id);
|
|
958
|
+
this.events?.emit({
|
|
959
|
+
kind: 'git-sync-failed',
|
|
960
|
+
workspaceId: id,
|
|
961
|
+
branch,
|
|
962
|
+
// Git stderr can quote a credentialed URL; the banner is user-facing and
|
|
963
|
+
// the string also lands in client logs, so sanitize before it leaves.
|
|
964
|
+
reason: sanitizeError(err),
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* A bare push wrapped in the sync tracker: a failure raises the banner, a
|
|
970
|
+
* success clears it. For the workflow paths that push directly — sharing a
|
|
971
|
+
* branch, publishing a CR's source, update-from-base, the decline revert,
|
|
972
|
+
* the roles.yaml restore — rather than through `pushWithRecovery`'s
|
|
973
|
+
* cooperative ladder. Behaviour is otherwise unchanged: the error still
|
|
974
|
+
* propagates to the caller exactly as the bare push's did. Without this,
|
|
975
|
+
* those pushes could fail invisibly (no banner) and, worse, a successful
|
|
976
|
+
* share could not CLEAR a banner an earlier save had raised.
|
|
977
|
+
*
|
|
978
|
+
* The branch is derived from the workspace id (they are the same string,
|
|
979
|
+
* URL-encoding aside) so call sites cannot pass a mismatched pair.
|
|
980
|
+
*/
|
|
981
|
+
private async trackedPush(
|
|
982
|
+
workspaceId: string,
|
|
983
|
+
user: AuthUser,
|
|
984
|
+
opts?: { systemAuthorized?: boolean },
|
|
985
|
+
): Promise<void> {
|
|
986
|
+
const branch = branchForWorkspaceId(workspaceId);
|
|
987
|
+
try {
|
|
988
|
+
// Preserve the exact call shape of the bare pushes this replaces — an
|
|
989
|
+
// explicit `undefined` third argument is a different call signature to
|
|
990
|
+
// every spy that asserts on it.
|
|
991
|
+
await (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user));
|
|
992
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
this.noteGitSyncFailed(workspaceId, branch, err);
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
899
999
|
/**
|
|
900
1000
|
* Shared implementation of "push, and on non-fast-forward try a
|
|
901
1001
|
* cooperative pull-rebase + retry, then hand off to the agent if that
|
|
@@ -911,20 +1011,24 @@ export class WorkflowService implements IWorkflowService {
|
|
|
911
1011
|
): Promise<void> {
|
|
912
1012
|
try {
|
|
913
1013
|
await this.git.push(workspaceId, user, opts);
|
|
1014
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
914
1015
|
} catch (firstPushErr) {
|
|
915
1016
|
const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
|
|
916
1017
|
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
|
|
917
1018
|
let recovered = false;
|
|
918
1019
|
let recoveryDetail = '(cooperative path not attempted)';
|
|
1020
|
+
let recoveryError: unknown = null;
|
|
919
1021
|
if (looksLikeNonFastForward) {
|
|
920
1022
|
try {
|
|
921
1023
|
await this.git.pull(workspaceId);
|
|
922
1024
|
await this.git.push(workspaceId, user, opts);
|
|
923
1025
|
recovered = true;
|
|
1026
|
+
this.noteGitSyncOk(workspaceId, branch);
|
|
924
1027
|
console.log(
|
|
925
1028
|
`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
|
|
926
1029
|
);
|
|
927
1030
|
} catch (recoveryErr) {
|
|
1031
|
+
recoveryError = recoveryErr;
|
|
928
1032
|
recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
|
|
929
1033
|
console.warn(
|
|
930
1034
|
`[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
|
|
@@ -933,6 +1037,14 @@ export class WorkflowService implements IWorkflowService {
|
|
|
933
1037
|
}
|
|
934
1038
|
}
|
|
935
1039
|
if (!recovered) {
|
|
1040
|
+
// Banner first, then the typed throw. The throw only reaches a UI that
|
|
1041
|
+
// is still mounted and listening — the release fired as an editor
|
|
1042
|
+
// unmounts has nowhere to surface it — whereas the event reaches every
|
|
1043
|
+
// session on the branch regardless of what triggered the push.
|
|
1044
|
+
// When recovery RAN, its error is the current state of the world (the
|
|
1045
|
+
// first rejection may be a stale non-fast-forward the pull already
|
|
1046
|
+
// cured); when it was skipped, the first error is all there is.
|
|
1047
|
+
this.noteGitSyncFailed(workspaceId, branch, recoveryError ?? firstPushErr);
|
|
936
1048
|
console.warn(
|
|
937
1049
|
`[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
|
|
938
1050
|
firstDetail,
|
|
@@ -1129,7 +1241,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1129
1241
|
|
|
1130
1242
|
getChangeRequestDetail(
|
|
1131
1243
|
number: number,
|
|
1132
|
-
opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
|
|
1244
|
+
opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
|
|
1133
1245
|
): Promise<ChangeRequestDetail | null> {
|
|
1134
1246
|
return this.prs.getPrDetail(number, opts);
|
|
1135
1247
|
}
|
|
@@ -1214,7 +1326,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1214
1326
|
// Push the source — `gh pr create` against an unpushed branch returns
|
|
1215
1327
|
// "head branch does not exist on remote". Push *after* the auto-merge
|
|
1216
1328
|
// so the remote sees the merge commit too.
|
|
1217
|
-
await this.
|
|
1329
|
+
await this.trackedPush(workspaceId, user);
|
|
1218
1330
|
|
|
1219
1331
|
const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
1220
1332
|
const cwd = path.join(workspacePath, this.kbDirName);
|
|
@@ -1367,7 +1479,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1367
1479
|
// If a new merge commit landed, push so the CR picks it up. When
|
|
1368
1480
|
// already up to date there's nothing to share — short-circuit the push.
|
|
1369
1481
|
if (!outcome.alreadyUpToDate) {
|
|
1370
|
-
await this.
|
|
1482
|
+
await this.trackedPush(workspaceId, user);
|
|
1371
1483
|
}
|
|
1372
1484
|
this.prs.invalidateDetailCache(number);
|
|
1373
1485
|
const refreshed = await this.prs.getPrDetail(number, {
|
|
@@ -1556,7 +1668,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1556
1668
|
`Revert ${repoRelPath} (declined in change request #${number})`,
|
|
1557
1669
|
true, // skipValidator — this restores an already-validated base version
|
|
1558
1670
|
);
|
|
1559
|
-
await this.
|
|
1671
|
+
await this.trackedPush(ws.id, user);
|
|
1560
1672
|
} finally {
|
|
1561
1673
|
// Committed inline — drop the lock row directly rather than enqueueing
|
|
1562
1674
|
// a duplicate commit through releaseLock.
|
|
@@ -1784,6 +1896,10 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1784
1896
|
fresh: true,
|
|
1785
1897
|
workspaceId,
|
|
1786
1898
|
viewerEmail: user.email,
|
|
1899
|
+
// Only `files[].path` is read below; a patch per file would be one
|
|
1900
|
+
// git subprocess each, generated for nothing, on every reject by a
|
|
1901
|
+
// non-author.
|
|
1902
|
+
patches: false,
|
|
1787
1903
|
});
|
|
1788
1904
|
const paths = detail?.files.map((f) => f.path) ?? [];
|
|
1789
1905
|
if (paths.length > 0) {
|
|
@@ -2105,7 +2221,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2105
2221
|
'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
|
|
2106
2222
|
);
|
|
2107
2223
|
}
|
|
2108
|
-
await this.
|
|
2224
|
+
await this.trackedPush(ws.id, user);
|
|
2109
2225
|
this.accessControl.invalidate(ws.id);
|
|
2110
2226
|
return true;
|
|
2111
2227
|
} finally {
|
|
@@ -454,7 +454,7 @@ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
|
|
|
454
454
|
|
|
455
455
|
beforeEach(async () => {
|
|
456
456
|
root = await mkTmpRoot();
|
|
457
|
-
// The fake `.git` in seedBranchWorkspace makes
|
|
457
|
+
// The fake `.git` in seedBranchWorkspace makes normalizeCloneConfig's
|
|
458
458
|
// git calls fail; that path only warns, which is noise here.
|
|
459
459
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
460
460
|
});
|
|
@@ -1065,3 +1065,48 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
1065
1065
|
}
|
|
1066
1066
|
});
|
|
1067
1067
|
});
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The tools are rooted at the WORKSPACE dir, one level above the git clone, so
|
|
1071
|
+
* a path has to start with the clone folder to reach git at all. Every place an
|
|
1072
|
+
* agent reads before choosing a path says so, in the `path` input itself rather
|
|
1073
|
+
* than only in prose it may not read: the root listing (where the folder is
|
|
1074
|
+
* discoverable) and each content-writing tool.
|
|
1075
|
+
*/
|
|
1076
|
+
describe('path inputs tell the agent about the repository folder', () => {
|
|
1077
|
+
// `toolDef` wraps a tool's inputs under a single `body` property.
|
|
1078
|
+
const inputDescription = (def: { inputs?: unknown } | undefined, ...keys: string[]): string => {
|
|
1079
|
+
let node = (def?.inputs ?? {}) as Record<string, unknown>;
|
|
1080
|
+
for (const key of ['body', ...keys]) {
|
|
1081
|
+
node = ((node.properties as Record<string, unknown> | undefined)?.[key] ?? {}) as Record<string, unknown>;
|
|
1082
|
+
if (key === 'files') node = (node.items ?? {}) as Record<string, unknown>;
|
|
1083
|
+
}
|
|
1084
|
+
return typeof node.description === 'string' ? node.description : '';
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
it('every content-writing tool says paths start with `knowledge-base/`, so a repo-relative path is never guessed', async () => {
|
|
1088
|
+
await start();
|
|
1089
|
+
const tools = await toolRegistry.listInternal();
|
|
1090
|
+
const byName = (name: string) => {
|
|
1091
|
+
const def = tools.find((t) => t.name === name);
|
|
1092
|
+
expect(def, name).toBeDefined();
|
|
1093
|
+
return def;
|
|
1094
|
+
};
|
|
1095
|
+
for (const name of ['write_file', 'edit_file', 'mkdir', 'delete_file', 'unzip']) {
|
|
1096
|
+
expect(inputDescription(byName(name), 'path'), name).toContain(`\`${KB_DIR}/\``);
|
|
1097
|
+
}
|
|
1098
|
+
expect(inputDescription(byName('write_files'), 'files', 'path'), 'write_files').toContain(`\`${KB_DIR}/\``);
|
|
1099
|
+
for (const name of ['move_file', 'copy_file']) {
|
|
1100
|
+
expect(inputDescription(byName(name), 'dest'), name).toContain(`\`${KB_DIR}/\``);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('the root listing names the clone folder, so an agent that lists first learns the prefix', async () => {
|
|
1105
|
+
await start();
|
|
1106
|
+
const tools = await toolRegistry.listInternal();
|
|
1107
|
+
const list = tools.find((t) => t.name === 'list_files');
|
|
1108
|
+
expect(list).toBeDefined();
|
|
1109
|
+
expect(list!.description).toContain(`\`${KB_DIR}/\``);
|
|
1110
|
+
expect(inputDescription(list, 'path')).toContain(`\`${KB_DIR}/\``);
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { KbStartupRunner } from '../kb-startup-runner.js';
|
|
8
|
+
import { WorkspaceService } from '../../workspace.service.js';
|
|
8
9
|
import { redactSecret } from '../kb-git.js';
|
|
9
10
|
import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
10
11
|
|
|
@@ -493,3 +494,143 @@ describe('redactSecret', () => {
|
|
|
493
494
|
expect(redactSecret('https://example.com/kb.git')).toBe('https://example.com/kb.git');
|
|
494
495
|
});
|
|
495
496
|
});
|
|
497
|
+
|
|
498
|
+
describe('KbStartupRunner credentials', () => {
|
|
499
|
+
// The phase clones the default branch into the very directory
|
|
500
|
+
// `WorkspaceService` adopts, and `GitService.push` later runs a bare
|
|
501
|
+
// `git push` from it. Position of the credential config is what decides
|
|
502
|
+
// whether that works: `git -c … clone` authenticates the clone and leaves
|
|
503
|
+
// the repo with nothing, so every later push prompts for a username, finds
|
|
504
|
+
// no tty, and dies — commits pile up locally and reach the remote never.
|
|
505
|
+
// Only real git tells the two spellings apart, so this drives real git.
|
|
506
|
+
const ORIGINAL = process.env.GITHUB_TOKEN;
|
|
507
|
+
afterEach(() => {
|
|
508
|
+
if (ORIGINAL === undefined) delete process.env.GITHUB_TOKEN;
|
|
509
|
+
else process.env.GITHUB_TOKEN = ORIGINAL;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
/** Run the phase far enough to put the default branch's clone on disk. */
|
|
514
|
+
async function materializeDefaultBranchClone(): Promise<string> {
|
|
515
|
+
let repoDir = '';
|
|
516
|
+
await makeRunner([
|
|
517
|
+
step('touch', async (ctx) => {
|
|
518
|
+
repoDir = await (await ctx.defaultBranch()).repoDir();
|
|
519
|
+
return { outcome: 'ok' };
|
|
520
|
+
}),
|
|
521
|
+
]).runAll();
|
|
522
|
+
expect(repoDir).toBe(path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base'));
|
|
523
|
+
return repoDir;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
it('leaves the boot clone able to authenticate a push of its own', async () => {
|
|
527
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
528
|
+
await populatedUpstream();
|
|
529
|
+
// The branch handle clones lazily, so a step has to actually reach for the
|
|
530
|
+
// repo before there is anything on disk to inspect.
|
|
531
|
+
const repo = await materializeDefaultBranchClone();
|
|
532
|
+
const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
|
|
533
|
+
expect(helper).toContain('username=x-access-token');
|
|
534
|
+
// The literal, not the value — the helper resolves it per invocation, so
|
|
535
|
+
// the secret never lands in a config file we don't control the lifetime of.
|
|
536
|
+
expect(helper).toContain('password=$GITHUB_TOKEN');
|
|
537
|
+
expect(helper).not.toContain('ghp_boot');
|
|
538
|
+
|
|
539
|
+
// Exactly one value: a re-stamp must collapse, never accumulate, or git
|
|
540
|
+
// errors with "cannot overwrite multiple values".
|
|
541
|
+
const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
542
|
+
expect(all.split('\n')).toHaveLength(1);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('adopting an existing clone collapses an accumulated helper back to one value', async () => {
|
|
546
|
+
// The boot-time assertion above sees a FRESH clone, which only ever has
|
|
547
|
+
// one value by construction. The collapse guarantee lives in the adoption
|
|
548
|
+
// path (`--replace-all`), so drift a second value in out of band and let
|
|
549
|
+
// WorkspaceService adopt the clone — that is the code the guarantee is
|
|
550
|
+
// about.
|
|
551
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
552
|
+
await populatedUpstream();
|
|
553
|
+
const repo = await materializeDefaultBranchClone();
|
|
554
|
+
// App-shaped drift: a second stamp of OURS with a stale username — the
|
|
555
|
+
// realistic accumulation (an operator's own helper is a different case,
|
|
556
|
+
// covered below, and must NOT be collapsed).
|
|
557
|
+
await git(repo, ['config', '--add', 'credential.helper',
|
|
558
|
+
'!f() { echo "username=stale-user"; echo "password=$GITHUB_TOKEN"; }; f']);
|
|
559
|
+
|
|
560
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
561
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
562
|
+
|
|
563
|
+
const all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
564
|
+
expect(all.split('\n')).toHaveLength(1);
|
|
565
|
+
expect(all).toContain('username=x-access-token');
|
|
566
|
+
expect(all).not.toContain('stale-user');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('an operator-configured helper survives both the stamp and the token-removal unset', async () => {
|
|
570
|
+
// git chains every configured helper, so an operator's clone-local
|
|
571
|
+
// `cache`/`store`/custom helper coexists with ours — and losing it on a
|
|
572
|
+
// token change would break the very fallback auth they set up.
|
|
573
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
574
|
+
await populatedUpstream();
|
|
575
|
+
const repo = await materializeDefaultBranchClone();
|
|
576
|
+
await git(repo, ['config', '--add', 'credential.helper', 'cache --timeout=300']);
|
|
577
|
+
|
|
578
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
579
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // stamp with token
|
|
580
|
+
let all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
581
|
+
expect(all).toContain('cache --timeout=300');
|
|
582
|
+
expect(all).toContain('password=$GITHUB_TOKEN');
|
|
583
|
+
|
|
584
|
+
delete process.env.GITHUB_TOKEN;
|
|
585
|
+
const ws2 = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
586
|
+
await ws2.getOrCreateForBranch(DEFAULT_BRANCH); // unset OUR helper only
|
|
587
|
+
all = (await git(repo, ['config', '--local', '--get-all', 'credential.helper'])).trim();
|
|
588
|
+
expect(all).toContain('cache --timeout=300');
|
|
589
|
+
expect(all).not.toContain('password=$GITHUB_TOKEN');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('adopting a clone after the token was removed unsets the stale helper', async () => {
|
|
593
|
+
// A deployment that lost its token must not keep a clone-local helper
|
|
594
|
+
// answering with an empty password — it would shadow whatever fallback
|
|
595
|
+
// auth the operator switched to.
|
|
596
|
+
process.env.GITHUB_TOKEN = 'ghp_boot';
|
|
597
|
+
await populatedUpstream();
|
|
598
|
+
const repo = await materializeDefaultBranchClone(); // stamped
|
|
599
|
+
delete process.env.GITHUB_TOKEN;
|
|
600
|
+
|
|
601
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
602
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
603
|
+
|
|
604
|
+
await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
|
|
605
|
+
.rejects.toMatchObject({ code: 1 });
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('a token added after the branch is cached reaches the clone without a restart', async () => {
|
|
609
|
+
// The rotation gap: the setup screen can supply a token AFTER a branch
|
|
610
|
+
// was first opened, and the cached fast path returns before the adoption
|
|
611
|
+
// re-stamp. The fingerprint check must catch the change there.
|
|
612
|
+
delete process.env.GITHUB_TOKEN;
|
|
613
|
+
await populatedUpstream();
|
|
614
|
+
const repo = await materializeDefaultBranchClone(); // no helper
|
|
615
|
+
|
|
616
|
+
const ws = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base', () => 'x-access-token');
|
|
617
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // adopt, tokenless
|
|
618
|
+
process.env.GITHUB_TOKEN = 'ghp_late';
|
|
619
|
+
await ws.getOrCreateForBranch(DEFAULT_BRANCH); // cached fast path
|
|
620
|
+
|
|
621
|
+
const helper = (await git(repo, ['config', '--local', '--get', 'credential.helper'])).trim();
|
|
622
|
+
expect(helper).toContain('password=$GITHUB_TOKEN');
|
|
623
|
+
expect(helper).not.toContain('ghp_late');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('clones without a helper when the deployment has no token — an open remote still boots', async () => {
|
|
627
|
+
delete process.env.GITHUB_TOKEN;
|
|
628
|
+
await populatedUpstream();
|
|
629
|
+
const repo = await materializeDefaultBranchClone();
|
|
630
|
+
// `--get` exits 1 when the key is unset. Assert on that exit code rather
|
|
631
|
+
// than on "it threw": a missing clone directory throws too, which would
|
|
632
|
+
// pass this test while proving nothing.
|
|
633
|
+
await expect(git(repo, ['config', '--local', '--get', 'credential.helper']))
|
|
634
|
+
.rejects.toMatchObject({ code: 1 });
|
|
635
|
+
});
|
|
636
|
+
});
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { cloneCredentialArgs, credentialHelperValue } from '../../kb-fs/clone-config.js';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
|
|
@@ -36,18 +37,30 @@ export function redactSecret(text: string): string {
|
|
|
36
37
|
*/
|
|
37
38
|
function credArgs(gitUsername: string): string[] {
|
|
38
39
|
const args = ['-c', 'core.longpaths=true'];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'-c',
|
|
42
|
-
`credential.helper=!f() { echo "username=${gitUsername}"; echo "password=$GITHUB_TOKEN"; }; f`,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
40
|
+
const helper = credentialHelperValue(gitUsername);
|
|
41
|
+
if (helper) args.push('-c', `credential.helper=${helper}`);
|
|
45
42
|
return args;
|
|
46
43
|
}
|
|
47
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `credArgs` authenticates the invocation and nothing more — the `-c` pairs sit
|
|
47
|
+
* BEFORE the subcommand, so git applies them to this process and forgets them.
|
|
48
|
+
* That is right for every command here except `clone`, whose product is a
|
|
49
|
+
* repository other code pushes from later: the phase clones the default branch
|
|
50
|
+
* into `<workspacesRoot>/<id>/<kbDirName>`, exactly where `WorkspaceService`
|
|
51
|
+
* adopts it, and `GitService.push` then runs a bare `git push` expecting the
|
|
52
|
+
* clone to carry its own credentials. Persist them with `clone --config` (which
|
|
53
|
+
* only means "write into the new repo" after the subcommand) so it does.
|
|
54
|
+
*/
|
|
55
|
+
function withPersistedCloneConfig(gitUsername: string, args: string[]): string[] {
|
|
56
|
+
if (args[0] !== 'clone') return args;
|
|
57
|
+
return [args[0], ...cloneCredentialArgs(gitUsername), ...args.slice(1)];
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
export async function git(cwd: string, gitUsername: string, args: string[]): Promise<string> {
|
|
49
61
|
try {
|
|
50
|
-
const
|
|
62
|
+
const argv = [...credArgs(gitUsername), ...withPersistedCloneConfig(gitUsername, args)];
|
|
63
|
+
const { stdout } = await execFileAsync('git', argv, {
|
|
51
64
|
cwd,
|
|
52
65
|
// A stalled remote must FAIL the phase, not hang the boot forever —
|
|
53
66
|
// fail-closed (and KB_SAFE_BOOT's demotion) can only engage on an error
|