feat(project): imperative harness deployment behind a feature flag - #2216
Draft
AlexanderRichey wants to merge 3 commits into
Draft
feat(project): imperative harness deployment behind a feature flag#2216AlexanderRichey wants to merge 3 commits into
AlexanderRichey wants to merge 3 commits into
Conversation
… context Introduces a small feature-flag facility so experimental behavior can ship behind an explicit switch. The first (and only) flag is AGENTCORE_CLI_EXPERIMENTAL_IMPERATIVE_DEPLOY, which nothing consumes yet; the next commit builds the imperative harness deployment on it. Design: - src/featureFlags/ declares FEATURE_FLAGS (code name -> env var), the consumer-facing FeatureFlags interface, and EnvFeatureFlags, which is constructed with an injected processEnv (never process.env directly) and reads it once so a flag cannot flip mid-command. The contract is narrow on purpose: a flag is on only when the variable's trimmed value is exactly "1". - FeatureFlagsKey joins the other root context keys; withFeatureFlags pins an instance the same way withGlobalConfigAccessor does. createRootHandler installs it for every command (CLI and TUI), defaulting to an instance with nothing enabled so the ~65 existing test call sites need no change. src/index.ts is the one place that passes process.env. - withLogging writes one debug line naming the enabled flags, only when at least one is on, so unflagged runs keep their exact log shape. - TestFeatureFlags (src/testing/) and a featureFlags option on renderScreen let handler and screen tests turn an experiment on. No behavior changes for users; README gains an "Experimental features" subsection documenting the =1 contract. Read first: src/featureFlags/env.ts, src/middleware/withFeatureFlags.tsx, src/handlers/index.tsx, src/middleware/withLogging.tsx. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014D5SZ5sApMQHrwd87VjDar
With AGENTCORE_CLI_EXPERIMENTAL_IMPERATIVE_DEPLOY=1, `project deploy` in a
harness-only project no longer synthesizes and deploys a CloudFormation
stack. It walks a plan of steps that call the AgentCore control plane
directly (CreateHarness/UpdateHarness/GetHarness/DeleteHarness, plus IAM for
the default execution role), polls until each harness is READY, and records
the result in agentcore/.cli/deployed-state.json. Without the flag, or in a
project that declares anything besides harnesses, nothing changes.
Design:
- src/core/plan/: a TypeScript port of the lightpress engine. A Plan is a
polytree of Steps with do() (issue the mutation) and status() (observe via a
read call). Roots run concurrently; a joined step runs after its last parent
(in-degree counting); children are queued on success; after a failure
in-flight steps finish but nothing new starts, and the error lists every
failed step. validatePlan rejects empty plans, duplicate names, and cycles
(DFS colouring rather than the Go visit-count heuristic). Concurrency is a
Promise.race loop over a set of running steps, bridged to the linear
ProgressEvent stream through AsyncChannel; every output line is prefixed
with its step name.
- src/core/project/backends/imperative/: ImperativeBackend implements
ProjectBackend. Per declared harness the plan is
[execution-role] -> [put-harness]; a harness recorded in state but no longer
declared gets a delete-harness root. put-harness resolves the harness id once
in preflight (state, else adoption by name via a paginated ListHarnesses),
reads NOT_STARTED for a missing/deleted harness, WAITING for CREATING or
UPDATING, and on READY compares the recorded appliedRequestHash with the
hash of the request it would send now, so an unchanged spec issues no call.
The hash is recorded only after READY is observed. harnessRequest.ts is the
spec -> CreateHarnessRequest/UpdateHarnessRequest mapping (the inverse of
mapServiceHarnessToSpec, held together by a round-trip test) plus the
preflight rejecting dockerfile, path skills, credentialName git auth, and
by-name memory references. The ExecutionRoleProvisioner interface is
declared by the backend and implemented over IAM in src/core/executionRole.tsx.
- Deployed state moved to src/core/project/deployedState.ts (the old path
re-exports). TargetState gains deploymentMode; resources gains a harnesses
map. The CDK backend now also records deploymentMode: "cdk"; older state with
a stackArn reads as cdk. FsProjectManager routes deploys by the requested
mode, refuses a mode that differs from the target's recorded one (the mode
guard), and routes status/invoke resolution by the recorded mode so they keep
working after the variable is unset.
- Mode selection lives in src/handlers/project/deploymentMode.ts and is shared
by the command and the TUI screen; the command prints one stderr line when
the flag is on but the project is not harness-only, the screen shows it as a
row.
Deviations from the brief, found during live verification:
- An omitted `memory` in harness.json is sent as { disabled: {} }, as the CDK
construct does: left out, the service auto-provisions a managed memory named
after the harness, which the default role policy does not cover, and the
first invoke fails with AccessDenied on ListEvents. For memory.mode "managed"
the desired policy widens the memory grant to `memory/<harnessName>-*`
(ExecutionPolicyOptions on desiredExecutionPolicy/ensureDefaultExecutionRole;
`harness create` passes nothing and keeps its exact document).
- An emptied project (remove all, or the last harness removed) also resolves
to imperative mode when the flag is on; otherwise the teardown of an
imperatively deployed target could never reach its backend.
- ImperativeBackendConfig.executionRoles is required rather than defaulted,
matching CdkBackendConfig.identity; FsProjectManager takes optional
harness/executionRoles and falls back to a backend that refuses to run, so
the existing direct constructions in tests needed no change.
- The two cdk.test.ts assertions on the exact state file gained the mandated
deploymentMode: "cdk" field; no other expectation changed.
- exportHarness now reads the harness directory through the shared
readHarnessDirectory, whose last-resort prompt is the scaffold's default
(no trailing period) rather than the export module's; unreachable for a
scaffolded harness, which always has system-prompt.md.
- UpdateHarness sends the memory, environmentArtifact and authorizerConfiguration
wrappers with no value when the spec dropped the field, so the service ends
up matching the spec; verified against the live service.
Read first: src/core/plan/plan.ts, src/core/project/backends/imperative/index.ts,
src/core/project/backends/imperative/harnessRequest.ts,
src/core/project/deployedState.ts, src/core/project/manager.tsx (deploy,
guardDeploymentMode, backendForTarget), src/handlers/project/deploymentMode.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5SZ5sApMQHrwd87VjDar
A harness directory now carries a skills/ directory next to harness.json and
system-prompt.md: one subdirectory per skill, each with a SKILL.md and whatever
files the skill needs. `project create` and `project add harness` scaffold it
with a README explaining the layout. On an imperative deploy every skill is
uploaded to an S3 bucket the CLI gets-or-creates for the account and region,
and the harness is created/updated with an { s3: { uri } } entry per skill
after whatever harness.json lists. The CDK path ignores the directory.
Design:
- src/core/project/skillsDir.ts discovers skills (immediate subdirectories of
skills/, walked recursively, junk and dotfiles skipped, MD5 per file) and
validates them before any AWS call: SKILL.md present, name matches
^[a-z0-9][a-z0-9._-]{0,63}$, no file over 5 GB (one PutObject), no duplicate
of an s3Uri already in harness.json. Bucket agentcore-skills-<account>-<region>,
prefix <project>/<harness>/skills/, URI s3://<bucket>/<prefix><skill>/.
- SkillsStore is declared by the backend (types.ts) and implemented over
@aws-sdk/client-s3 in src/core/skillsStore.ts: HeadBucket (404 absent, 403
forbidden), CreateBucket with LocationConstraint outside us-east-1 and
BucketAlreadyOwnedByYou as success, PutPublicAccessBlock, paginated
ListObjectsV2, single PutObject per file (streamed, ContentMD5), DeleteObjects
in batches of 1000. CoreClient gains an s3() accessor and a createS3Client
factory like the other clients; fixtureFactories records it too.
- The plan per harness is now the polytree
[execution-role] ──────────────────────┐
├──► [put-harness]
[skills-bucket] ──► [sync-skills] ─────┘
with one skills-bucket step shared by every harness of the target. The
skills branch exists when the harness has skills or state records a previous
sync, so removing the last skill still empties the prefix. sync-skills diffs
{key -> md5} against {key -> etag} and uploads/deletes only the difference,
reporting one line per object through a new optional reporter argument the
engine passes to Step.do. A harness dropped from the spec (and a teardown)
gets a delete-skills root next to delete-harness; the bucket is never deleted.
- The execution policy gains s3:GetObject on the prefix and a prefix-scoped
s3:ListBucket only while the harness has skills (ExecutionPolicyOptions
skillsBucket/skillsPrefix); the role step's desired-document comparison
means a harness gaining or losing its skills causes exactly one
PutRolePolicy. A user-supplied executionRoleArn is never touched; the deploy
prints a reminder of the access the role needs.
- State records { bucket, prefix, manifestHash } under the harness's skills
after a sync is observed complete. The skills manifest hash is folded into
the recorded request hash, so a changed skill file (same URIs) still
triggers one UpdateHarness and a new version; verified live.
Deviations from the brief:
- No skillsBucket override in aws-targets.json; the "forbidden" case fails
with a message saying so (documented in the README as a follow-up).
- SkillsStore.put takes a { absolutePath, size, md5 } source rather than a
stream, so the store owns how it reads the file (a node stream, which the
Node bundle can run) and fakes stay trivial.
- The skills README is an inlined constant (templates/harnessSkills.ts), as
the harness template inlines its default prompt and has no asset source.
- Two AwsClients test stubs and one gateway test stub gained an s3 member;
no expectation changed.
Read first: src/core/project/skillsDir.ts, src/core/skillsStore.ts,
src/core/project/backends/imperative/index.ts (harnessSubtree,
syncSkillsStep, removalSteps, recordState), src/core/executionRole.tsx
(ExecutionPolicyOptions), src/core/project/templates/harnessSkills.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5SZ5sApMQHrwd87VjDar
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## refactor #2216 +/- ##
============================================
+ Coverage 97.07% 97.15% +0.07%
============================================
Files 544 559 +15
Lines 37866 39261 +1395
============================================
+ Hits 36760 38143 +1383
- Misses 1106 1118 +12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Contributor
|
Claude Security Review: no high-confidence findings. (run) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds an imperative deployment path for harness-only projects, entirely behind
AGENTCORE_CLI_EXPERIMENTAL_IMPERATIVE_DEPLOY=1. With the flag set,agentcore project deployin a project that declares only harnesses skips CloudFormation and CDK altogether: it walks a small plan of steps that call the AgentCore control plane directly (CreateHarness/UpdateHarness/GetHarness/DeleteHarness, plus IAM for the default execution role and S3 for a newskills/directory), polls each harness toREADY, and records the result inagentcore/.cli/deployed-state.json. Without the flag, or in a project that declares anything else, nothing changes; the CDK path and its tests are untouched apart from one new state field (deploymentMode: "cdk") it now records. Three commits: the feature-flag client, the imperative deploy on a ported plan engine, and the S3-syncedskills/directory.How it works
Feature flags.
src/featureFlags/maps a code name (imperativeDeploy) to its environment variable and reads it once at construction through an injectedprocessEnv; a flag is on only when the trimmed value is exactly1.FeatureFlagsKeyjoins the root context keys,withFeatureFlagspins an instance at the root for every CLI command and TUI screen,src/index.tsis the one placeprocess.envis passed in, andwithLoggingwrites one debug line naming the enabled flags.TestFeatureFlagsand afeatureFlagsoption onrenderScreenlet tests switch an experiment on.Plan engine (
src/core/plan/). A TypeScript port of the lightpress prototype: aPlanis a polytree ofSteps, each withdo()(issue one mutating call) andstatus()(observe through a read call and reportNOT_STARTED | WAITING | SUCCESSFUL | FAILED). The engine runs roots concurrently, releases a joined step when its last parent succeeds (in-degree counting), queues children on success, and on the first failure lets in-flight steps finish without scheduling anything new, then throws aProjectStateErrorlisting every failed step. Validation rejects empty plans, duplicate names, and cycles (DFS colouring). Because every step reads before it writes, a plan is idempotent, self-healing, and resumable with no bookkeeping of its own.The harness polytree. Per declared harness,
ImperativeBackendbuildswith one
skills-bucketstep shared by every harness of the target, the skills branch present only when the harness has askills/directory or had one synced before, and the role step omitted for a user-suppliedexecutionRoleArn. Harnesses recorded in state but no longer declared get independentdelete-harness/<name>anddelete-skills/<name>roots.Create / update / remove. Identity is resolved once in preflight: the recorded
harnessId, else adoption by name through a paginatedListHarnesses(two matches is an error).put-harnessreadsNOT_STARTEDfor a missing or externally deleted harness,WAITINGforCREATING/UPDATING, fails with thefailureReasonand a delete hint onCREATE_FAILED, retriesUPDATE_FAILEDunder the engine's attempt cap, and onREADYcompares the recordedappliedRequestHashwith the hash of the request it would send now (spec →CreateHarnessRequestviaharnessRequest.ts, the inverse ofmapServiceHarnessToSpec, plus the skills manifest hash). Equal means no call at all; the hash is recorded only afterREADYis observed.UpdateHarnessalways sends the spec-owned collections (tools, skills, allowed tools, environment variables) so a removed entry is removed on the service.sync-skillsdiffs{key → md5}against the bucket's{key → etag}and uploads or deletes only the difference; the execution policy gains a prefix-scoped S3 grant only while the harness has skills. A spec that declares nothing (afterproject remove all) tears every recorded harness and its objects down after the same--yesconfirmation the CDK path asks for; the bucket is never deleted.Deployed state.
deployedState.tsmoved frombackends/cdk/tosrc/core/project/(the old path re-exports). A target gainsdeploymentMode: "cdk" | "imperative"andresources.harnesses[name] = { harnessId, harnessArn, executionRoleArn?, appliedRequestHash?, skills?: { bucket, prefix, manifestHash } }. The CDK backend now also recordsdeploymentMode: "cdk"; older state with astackArnreads ascdk.statusandinvokeroute by the recorded mode, not the flag, so they keep working after the variable is unset.Mode guard.
resolveDeploymentModepicksimperativeonly when the flag is on and the project is harness-only (or emptied, so a teardown reaches the right backend); otherwise the command prints one stderr line explaining why CDK is used (the TUI shows it as a row).FsProjectManagerrefuses a deploy whose mode differs from the target's recorded one with a message that says how to switch, so a target is never deployed both ways.Intentionally unsupported
Rejected in a preflight before any AWS call, listing every offending field:
dockerfile(needs the CodeBuild pipeline the CDK path provisions),skillsentries of the{ "path": … }form (they must be baked into a container image; theskills/directory is the alternative),skills[].auth.credentialName(usecredentialArn), andmemory.mode: "existing"referenced bynamerather thanarn. Inskills/: a directory withoutSKILL.md, a name outside^[a-z0-9][a-z0-9._-]{0,63}$, a file over 5 GB, or a skill whose URIharness.jsonalready lists. A skills bucket name taken by another account fails with an explanation; there is no override yet.Two behaviours found during live verification and deliberately mirrored from the CDK construct: an omitted
memoryis sent as{ disabled: {} }(left out, the service auto-provisions a managed memory the default role cannot read, and the first invoke fails), and amanagedmemory widens the role's memory grant to the harness-named memory the API creates.Follow-ups
skillsBucketoverride inaws-targets.jsonfor accounts whose derived bucket name is taken.dockerfilesupport, once a build pipeline exists outside CDK.DELETINGasWAITINGrather than failing, so a redeploy right after an out-of-band delete self-heals without a rerun.Verification
Hermetic suite.
bun run lint:check,bun run format:check,bun run typecheck,bun test,bun run build, andbun install --frozen-lockfilepass at each of the three commits (Node 22 installed so the pre-existingsrc/io/exec.test.tscases run). Final run: 3120 tests, 0 failures across 219 files. Line coverage of the new modules:featureFlags/*,plan/*,backends/imperative/*,deployedState.ts,harnessDir.ts,skillsDir.ts,skillsStore.ts,deploymentMode.ts,templates/harnessSkills.tsat 100%;executionRole.tsxat 99.7%. Tests use fakes at the constructor boundaries (an in-memory control plane simulatingCREATING → READY, a role provisioner, a skills store) and at the SDK.send()seam (IAM and S3), matchingcdk.test.ts.Live, against us-east-1 in account 501930284170, from source with
AGENTCORE_TELEMETRY_DISABLED=1, projectsImpE2ECA1a(commit 2) andImpE2E9afJ(commit 3):Commit 1
bun run src/index.ts --helpworks. With the variable=1,~/.agentcore/logs/output-*.loggained{"msg":"experimental feature flags enabled","featureFlags":["imperativeDeploy"],…}; with=0or unset, no such line.harness list --region us-east-1 --jsonstill lists harnesses.Commit 2
project create --name ImpE2ECA1a --skip-install --skip-git, thenAGENTCORE_CLI_EXPERIMENTAL_IMPERATIVE_DEPLOY=1 project deploy --json: created the default target (aws-targets.json), roleAgentCoreHarness-ImpE2ECA1a, and harnessImpE2ECA1a-ukgI8yJJW8, polled to READY (≈2.5 min), printedDeployed project 'ImpE2ECA1a' to target 'default';outputscarriedharness.ImpE2ECA1a.idand.arn; state recordeddeploymentMode: "imperative"and the hash.aws bedrock-agentcore-control get-harness:READY, version 1, the scaffolded prompt andglobal.anthropic.claude-sonnet-4-6;aws iam get-role-policy:AgentCoreHarnessExecutionPolicywith 19 statements.AccessDeniedException … bedrock-agentcore:ListEvents on memory/ImpE2ECA1a-MnHR2R83Tq: the service had auto-provisioned a managed memory for the omittedmemory. Fixed by sendingdisabled(as CDK does); the next deploy issued exactly one update (version 2,memory: {disabled: {}}, the clear wrappers accepted), andproject invoke harness --prompt "Say hello in five words." --jsonrepliedHello there, how are you!.project status --jsonwithout the variable:deploymentState: "deployed", idImpE2ECA1a-ukgI8yJJW8.harness version listwas['2','1']before and after; progress showedexecution-role: satisfiedandput-harness: satisfied.system-prompt.md(pirate prompt), redeployed: oneput-harness: issued, versions['3','2','1'],get-harnessshows the new prompt, invoke repliedAhoy there, matey! Welcome aboard! Arr!.Target 'default' of project 'ImpE2ECA1a' was deployed in imperative mode, but this deploy would run in cdk mode. Set AGENTCORE_CLI_EXPERIMENTAL_IMPERATIVE_DEPLOY=1 …; harness count unchanged.aws-targets.jsonaccount edited to111122223333:Deployment target 'default' expects AWS account 111122223333, but the active credentials belong to 501930284170.afterVerifying AWS account, versions unchanged.agentcore harness delete --id ImpE2ECA1a-ukgI8yJJW8, redeploy while stillDELETING: failed with the DELETING message (by design); after the delete finished, redeploy recreatedImpE2ECA1a-mxmyhOXapHand state carried the new id.project remove harness --name ImpE2ECA1athen deploy--yes: first attempt exposed that an emptied project resolved to CDK and hit the guard (fixed: emptied projects count as harness-only); thendelete-harness/ImpE2ECA1aran,get-harnessreturnedResourceNotFoundException, state became{"targets":{}}, and a further deploy reported nothing to remove.Commit 3
project create --name ImpE2E9afJ:app/ImpE2E9afJ/skills/README.mdscaffolded.secret-greeting/(SKILL.md+data/greeting.txt) andrelease-notes/(SKILL.md+templates/notes.md), deployed:aws s3api head-bucketfoundagentcore-skills-501930284170-us-east-1(it did not exist before),get-public-access-blockall four true,aws s3 ls … --recursivelisted all four files,get-harnesslisted boths3skills,get-role-policyheldAgentCoreSkillsReadon…/ImpE2E9afJ/ImpE2E9afJ/skills/*andAgentCoreSkillsListwith thes3:prefixcondition; state recordedskills: { bucket, prefix, manifestHash }."Use your secret-greeting skill: what is the secret greeting?": the transcript shows theskillstool loading.agents/skills/s3/secret-greeting/SKILL.md,file_operationsviewingdata/greeting.txt, and the replyPurple walrus at dawn. (Later invokes loaded the skill but the model did not always reach for the file tool.)list-objects-v2ETags/LastModified identical, harness version still 1, progresssync-skills: satisfied.greeting.txt, redeployed: only that key's ETag/LastModified changed, harness version 2.release-notes/, redeployed: its two objects gone,get-harnesslists ones3skill, version 3.secret-greeting/, redeployed:sync-skillsstill ran and emptied the prefix,execution-rolere-issued and the policy lost both S3 statements, harness version 4 withskills: [], state droppedskills.skills/broken/notes.mdwithoutSKILL.md:Harness 'ImpE2E9afJ' has a skill the deploy cannot upload: - 'skills/broken': missing SKILL.md …after the account check and before any mutation (version still 4).project remove all --yesand deploy--yes:delete-harnessanddelete-skillsran in parallel,get-harness→ResourceNotFoundException, prefix empty, bucket still present, state{"targets":{}}.Cleanup. Every resource created in this session was removed: both harnesses (via teardown/out-of-band delete), the auto-provisioned memory
ImpE2ECA1a-MnHR2R83Tq, rolesAgentCoreHarness-ImpE2ECA1aandAgentCoreHarness-ImpE2E9afJ, every object under the test prefixes, and the bucketagentcore-skills-501930284170-us-east-1(created in this session, empty).list-harnesses,list-roles,list-memories, andlist-bucketsfiltered on theImpE2E/agentcore-skillsprefixes all return nothing; pre-existing resources were not touched.Reviewer guide
Commit 1 (
113d5bf4):src/featureFlags/env.ts,src/middleware/withFeatureFlags.tsx,src/handlers/index.tsx,src/middleware/withLogging.tsx,src/testing/featureFlags.tsx.Commit 2 (
1a113855):src/core/plan/plan.ts(the engine),src/core/project/backends/imperative/index.ts(the backend:harnessSubtree,putHarnessStep,resolveIdentities,recordState,teardown),src/core/project/backends/imperative/harnessRequest.ts(mapping + preflight; see the round-trip test),src/core/project/deployedState.ts,src/core/project/manager.tsx(deploy,guardDeploymentMode,backendForTarget),src/handlers/project/deploymentMode.ts,src/core/executionRole.tsx(ExecutionPolicyOptions,createIamExecutionRoleProvisioner).Commit 3 (
c1eac66f):src/core/project/skillsDir.ts,src/core/skillsStore.ts,src/core/project/backends/imperative/index.ts(syncSkillsStep,skillsBucketStep,removalSteps),src/core/executionRole.tsx(the S3 statements),src/core/project/templates/harnessSkills.ts, README "Experimental features".🤖 Generated with Claude Code
https://claude.ai/code/session_014D5SZ5sApMQHrwd87VjDar